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:

CrateWrapsReach it asDepends on
serde-hl7-v2hl7-2: the message, the tree, the findings, the releaseserde_hl7::v2serde, hl7-2
serde-hl7-v3hl7-3: the envelope, the six RIM classes, the data types, the element treeserde_hl7::v3serde, hl7-3
serde-hl7the two above, behind v2 and v3 features, both on by default—the two above
sh
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 directly

Every 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

rust
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:

json
{
  "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

rust
// 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

rust
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:

json
{
  "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

rust
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:

json
[
  {
    "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.

rust
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

rust
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 lost

Keys 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

json
{
  "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

rust
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

rust
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 there

v3: nested to any depth

rust
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

rust
// 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 store

serde_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.