Guides
Serde: JSON, YAML, and any format
Put a parsed HL7 v2 message, its dictionary-named tree, its validation findings, or a decoded HL7 v3 interaction through any Serde data format — and get it back. Three small crates, two dependencies each, and the parsing crates themselves never see serde.
Three crates, and why they are separate
hl7-2 has one dependency and hl7-3 has one, and neither is serde — on
purpose, because healthcare dependency trees get audited and a parser should not cost every
caller a framework they may not want. The Serde support is a bridge you add beside them:
| Crate | Wraps | Reach it as | Depends on |
|---|---|---|---|
serde-hl7-v2 | hl7-2: the message, the tree, the findings, the release | serde_hl7::v2 | serde, hl7-2 |
serde-hl7-v3 | hl7-3: the envelope, the six RIM classes, the data types, the element tree | serde_hl7::v3 | serde, hl7-3 |
serde-hl7 | the two above, behind v2 and v3 features, both on by default | — | the two above |
cargo add serde-hl7 # serde_hl7::v2 and serde_hl7::v3
cargo add serde-hl7 --no-default-features --features v2 # one standard only
cargo add serde-hl7-v2 # or the sub-crate directlyEvery type is a same-named wrapper — serde_hl7::v2::Message around hl7_2::Message — that Derefs to what it wraps and converts both ways
with From, so the wrapped crate's whole API is reachable on the wrapper and nothing
has to be re-learned. Every Serialize and Deserialize impl is written
by hand against Serde's low-level traits: no derive, no proc-macro crate, and shapes that were
chosen rather than inherited from a struct layout.
A v2 message: ER7 text plus release
use serde_hl7::v2::Message;
let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815081500||ORU^R01^ORU_R01|MSG00042|P|2.5\r\
PID|1||444333222^^^ACME^MR||EVERYWOMAN^EVE^E||19620320|F";
let message = Message::parse(text)?;
let json = serde_json::to_string(&message)?;
let back: Message = serde_json::from_str(&json)?;
assert_eq!(back.to_er7(), text); // the ER7 text is unchanged
assert_eq!(back.version(), message.version()); // and so is the release
assert_eq!(back.get("PID-5.1")?.as_deref(), Some("EVERYWOMAN"));Which, as JSON, is two keys:
{
"version": "2.5",
"er7": "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815081500||ORU^R01^ORU_R01|MSG00042|P|2.5\rPID|1||444333222^^^ACME^MR||EVERYWOMAN^EVE^E||19620320|F"
}er7 is the message as ER7 text, which hl7-2 already guarantees
round-trips byte for byte. version is the release the message was read
as — which a forced Options::version, or a 2.5.2 resolved to 2.5.1, may have made different from what MSH-12 literally says — so that survives
too. Deserializing parses the text again with that version pinned, so the value that comes back
carries a resolved dictionary exactly as a freshly parsed message would; the dictionary itself
is never on the wire.
Wrapping a message you already have
// Already holding an hl7_2::Message? Wrap it; Deref reaches the whole hl7-2 API.
let message = Message(hl7_2::parse(text)?);
let message: Message = hl7_2::parse(text)?.into();
let inner: hl7_2::Message = message.into();The dictionary-named tree
use serde_hl7::v2::Node;
let json = serde_json::to_string_pretty(&Node(message.tree()))?;One object per node, six keys always present. The PID.5 node of the message above:
{
"name": "PID.5",
"path": "PID[1]-5[1]",
"kind": "Field",
"text": "EVERYWOMAN^EVE^E",
"null": false,
"children": [
{ "name": "XPN.1", "path": "PID[1]-5[1].1", "kind": "Component", "text": "EVERYWOMAN", "null": false, "children": [] },
{ "name": "XPN.2", "path": "PID[1]-5[1].2", "kind": "Component", "text": "EVE", "null": false, "children": [] },
{ "name": "XPN.3", "path": "PID[1]-5[1].3", "kind": "Component", "text": "E", "null": false, "children": [] }
]
}name is the dictionary name (PID.5, XPN.1, or the
message structure at the root); path is the er7 path that reads the
node back, empty at the root; kind is one of Group, Segment, Field, Component, Subcomponent; null is true only where the sender wrote the explicit HL7 null "", kept distinct from an absent value; children is [] at a leaf.
Node is Serialize only. hl7-2 builds a tree only from a
parsed message and hl7_2::Node has no public constructor, so the only way back to
a tree is through a Message. A tree is a view; the message is what
round-trips.
Validation findings
use serde_hl7::v2::Diagnostic;
let findings: Vec<Diagnostic> = message.validate().into_iter().map(Diagnostic).collect();
let json = serde_json::to_string_pretty(&findings)?;
let back: Vec<Diagnostic> = serde_json::from_str(&json)?;
assert_eq!(back, findings);For a message that carries a Z-segment, one warning:
[
{
"severity": "Warning",
"kind": "StructureMismatch",
"path": "",
"detail": "the standard segments fit structure ORU_R01, but the message also carries local Z-segments, which no structure describes; segments are read flat"
}
]Four keys, all required on the way back in — severity is Error or Warning, kind is the variant name — so an API can return what validate() found and the other side can read it back as diagnostics rather than
as prose. Note that detail can quote a value from the message, as Patient data explains; serializing a finding does not change
that.
Reading back through your own dictionary
A plain Deserialize impl is stateless: it has no way to be handed hl7_2::Options, so it reads every message through the bundled dictionary for the
release on the wire. A message that was parsed in schema
mode comes back through Message::seed, a DeserializeSeed that
carries the options — a vendor dictionary, a forced release, strict validation — into the
re-parse.
use std::sync::Arc;
use serde::de::DeserializeSeed;
use serde_hl7::v2::Message;
let dictionary = hl7_2::Dictionary::from_json(
r#"{"inherits": "2.5", "segments": {"ZPD": ["ST", "XPN"]}}"#,
"acme",
)?;
let options = hl7_2::Options::new().with_dictionary(Arc::new(dictionary));
let json = r#"{"version":"2.5","er7":"MSH|^~\\&|ACME||||1||ADT^A01|1|P|2.5\rZPD|7|SMITH^JOHN"}"#;
let mut deserializer = serde_json::Deserializer::from_str(json);
let message = Message::seed(&options).deserialize(&mut deserializer)?;
// The vendor's own segment reads through the vendor's dictionary.
assert_eq!(message.tree().find("XPN.2").unwrap().text(), "JOHN");A version set in the options wins over the version key on the wire,
which is what that option means for parse_with_options. The seed's .strict() makes it reject unknown keys, the same as Strict<Message> below.
A v3 interaction
use serde_hl7::v3::Message;
let message = Message::parse(xml)?;
let json = serde_json::to_string_pretty(&message)?;
let back: Message = serde_json::from_str(&json)?;
assert_eq!(back, message); // every field of every type is public, so nothing is lostKeys are the hl7-3 field names in lowerCamelCase — which, wherever a field
corresponds to an HL7 v3 attribute or element, is that name, so the JSON and the XML
cross-reference without a table. Every key is serialized every time: an Option is null when absent, a Vec is [] when empty. The sender, receiver, and domain payload are the raw element tree — name, attributes as an object in name order, text, children — because hl7-3 reads them that way.
A PRPA_IN201305UV02, abbreviated
{
"id": { "root": "2.16.840.1.113883.19.5", "extension": "MSG00042" },
"creationTime": "20260815081500",
"interactionId": { "root": "2.16.840.1.113883.1.6", "extension": "PRPA_IN201305UV02" },
"sender": {
"name": "device",
"attributes": { "classCode": "DEV", "determinerCode": "INSTANCE" },
"text": "",
"children": [ { "name": "id", "attributes": { "root": "2.16.840.1.113883.19.5.1" }, "text": "", "children": [] } ]
},
"receiver": { "name": "device", "attributes": { "classCode": "DEV", "determinerCode": "INSTANCE" }, "text": "", "children": [ ... ] },
"controlAct": {
"classCode": "CACT",
"moodCode": "EVN",
"code": { "code": "PRPA_TE201305UV02", "codeSystem": null, "displayName": null },
"domain": { "name": "patient", "attributes": { "classCode": "PAT" }, "text": "", "children": [ ... ] }
}
}On the way back in, only the keys that name a thing are required — classCode and moodCode on an act, typeCode on a
participation, root on an identifier, code on a code, and an
element's name; everything else defaults, matching hl7-3's rule that
a missing wrapper reads as absent rather than failing.
The payload as a RIM class rather than raw elements
use serde_hl7::v3::{Act, Role};
let domain = message
.control_act
.as_ref()
.and_then(|act| act.domain.as_ref())
.expect("the sample carries a payload");
// The payload's shape is the interaction's business: hl7-3 reads it into
// whichever RIM class you say it is, and the wrapper serializes the typed value.
let patient = Role(hl7_3::rim::Role::from_element(domain));
let json = serde_json::to_string_pretty(&patient)?;
// {
// "classCode": "PAT",
// "id": [ { "root": "2.16.840.1.113883.19.5", "extension": "444333222" } ],
// "code": null,
// "statusCode": { "code": "active", "codeSystem": null, "displayName": null },
// "effectiveTime": "20260101"
// }
let back: Role = serde_json::from_str(&json)?;
assert_eq!(back, patient);Strict mode
Deserializing ignores a key it does not recognize, by default, matching both wrapped crates'
own read-what-you-can behavior. That is right for reading what a sender sent and exactly the
situation in which a typo in a hand-written fixture is silent. Strict<T> is
the opt-in: a separate type, chosen at the one call site that wants it, never a global flag.
v2: the one optional key
use serde_hl7::v2::{Message, Strict};
// "verison" — a typo of "version", the one optional key this type has.
let typo = r#"{"verison": "2.3.1", "er7": "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815||ADT^A01|1|P|2.5"}"#;
let plain: Message = serde_json::from_str(typo)?; // Ok — read as 2.5, from MSH-12
let strict: Result<Strict<Message>, _> = serde_json::from_str(typo);
assert!(strict.is_err());
// unknown field `verison`, expected `version` or `er7`
let message: Strict<Message> = serde_json::from_str(&typo.replace("verison", "version"))?;
assert_eq!(message.version().to_string(), "2.3.1"); // Deref: the wrapped API is right therev3: nested to any depth
use serde_hl7::v3::{Message, Strict};
// A wrong key three levels down, inside the payload's id element.
let fixture = r#"{"controlAct":{"classCode":"CACT","moodCode":"EVN",
"domain":{"name":"patient","children":[{"name":"id","attrs":{"root":"1.2.3"}}]}}}"#;
let plain: Message = serde_json::from_str(fixture)?; // Ok — "attrs" ignored, id has no attributes
let strict: Result<Strict<Message>, _> = serde_json::from_str(fixture);
assert!(strict.is_err());
// unknown field `attrs`, expected one of `name`, `attributes`, `text`, `children`In v3 the check reaches every object type at every depth — identifiers, codes, and every
element of the payload tree — but leaves an element's attributes map alone:
attribute names are the document's data, and no crate can know which ones an element should
have.
Any format, not just JSON
// The crates name no format. Pick one in your own Cargo.toml and use it as you would
// for any Serialize type — serde_json here only because JSON is the easiest to read.
let json = serde_json::to_string(&message)?;
let value = serde_json::to_value(&message)?; // or an in-memory Value
let bytes = serde_json::to_vec(&message)?; // or bytes for a storeserde_json is a dev-dependency of these crates and nothing more; their library
code names no format. The examples on this page use it because JSON is the easiest to read on a
page, and the same calls work with a YAML crate, bincode, or any other Serde data
format you add to your own manifest. Because every shape is chosen — a string for a release, an
object for a node, a bare code for a nullFlavor — nothing depends on a feature
only one format has.
This is not the conversion crate
Converting formats is about a different question. The hl7-2-from-er7-into-json document —
one key per field, named by data type, real arrays for repetitions — is a fixed mapping for a
consumer that is not Rust, with a reverse crate to bring it back. The Serde shapes here are for
Rust code that already holds a parsed value and needs it to pass through a document store, a
web framework's response type, a structured logger, or a snapshot test. Same message, two
shapes for two consumers; neither is a substitute for the other, and neither crate depends on
the other.
Each crate's normative spec/index.md — v2, v3 —
states every wire shape and every rule, with a coverage table cargo test checks.
Runnable programs are in each crate's examples/; see Examples for the commands.