Documentation

Concepts

The vocabulary the rest of this site assumes. If you are new to HL7 this is the page to read first; if you are not, skim it for the two or three places where these crates make a specific choice.

HL7 is a family, not a standard

“HL7” names an organization and several unrelated standards it published. They have little in common beyond the name and the problem:

HL7 v2
Delimited text. Releases 2.1 through 2.9, first published in the late 1980s, and still the format most healthcare data actually moves in. Flexible to the point of being negotiable — which is why a “v2 parser” that does not know which release it is reading is not much of a parser. Handled by hl7-2.
HL7 v3
XML, generated from one strict object model. It replaced v2's flexibility with rigor, and bought consistency at the cost of a steep learning curve. V3 messaging saw limited adoption; what did succeed, and still runs, is the Clinical Document Architecture and national registries built on the same model. Handled by hl7-3.
FHIR
Resources over HTTP, the current direction of travel. Not implemented in this workspace — but the umbrella crate deliberately leaves hl7::fhir free for it.

That is why the umbrella crate has one module per standard and nothing at its root. A “message”, a “segment”, and a “code” all mean different things in each, and one flat namespace would only invite mixing them up.

ER7: the pipes and carets

ER7 — Encoding Rules version 7 — is v2's traditional wire encoding: the pipe-delimited text everybody pictures when they hear “HL7”. The delimiters are not fixed by the standard. They are declared by each message, in its own first line:

er7
MSH|^~\&|LAB|ACME|EHR|CLINIC|20260814080000||ORU^R01|MSG00042|P|2.5

MSH-1 is the character immediately after MSH — here |, the field separator. MSH-2 is the next four characters — ^~\& — which are the component, repetition, escape, and subcomponent separators in that order. A parser that hardcodes those five characters will misread any sender that chose differently, and senders do.

In this workspace the ER7 layer is its own crate, er7, outside this repository and with no dependencies of its own. It owns delimiters, escapes, paths, byte-for-byte rendering, and batch splitting — the syntax. Everything above it owns the meaning.

The anatomy of a v2 message

A message is a sequence of segments, one per line, each named by three characters. A segment is a sequence of fields. A field may repeat, may split into components, and a component may split into subcomponents. Four levels, four separators.

text
PID|1||444333222^^^ACME&1.2.3.4&ISO^MR||EVERYWOMAN^EVE^E
 ^  ^  ^                                       ^
 |  |  |                                       |
 |  |  |                                       PID-5, an XPN (person name)
 |  |  PID-3, a CX (identifier), whose 4th component
 |  |  is itself an HD with subcomponents split by &
 |  PID-2, not sent
 PID-1, the set ID

Field       PID-3            separated by |
Repetition  PID-3[2]         separated by ~
Component   PID-3.4          separated by ^
Subcomponent PID-3.4.2       separated by &

Names describe, paths address

This is the distinction worth internalising early, because both vocabularies appear in the same output and they are not interchangeable.

  • A path is an address: PID-5.1 means segment PID, field 5, component 1. Paths are what get, set, and the CLI's --query take, and they are what the derive macros' attributes hold.
  • A name is a description: XPN.1 means the family-name component of an extended person name. Names are what the tree, the XML elements, and the JSON keys use, and they come from the dictionary.
rust
message.get("PID-5.1")        // segment PID, field 5, component 1
message.get("OBX[2]-5.2")     // the second OBX segment
message.get("PID-3[2].1")     // the second repetition of PID-3
message.get("PID-3.4.2")      // down to a subcomponent

// The tree speaks the other vocabulary:
tree.find("XPN.1")            // "the family-name component of a person name"
tree.find_all("OBX")          // every OBX node
node.path()                   // the path that reads this node back

The dictionary

The dictionary is the per-release knowledge that turns positions into meaning: which data type each field of each segment carries, which components each composite type has, and what the message structures look like. v2.5 is the complete base; every other release from 2.1 to 2.9 is a delta of it, covering the differences these crates model today, and inheriting the rest.

The release is chosen from MSH-12, or forced with Options::with_version. A release string with no dictionary resolves to the nearest older one — 2.5.2 reads as 2.5.1 — rather than failing.

A dictionary is also a file you can write. That is the whole of schema mode: state a vendor's dialect as JSON, inherit a bundled release, and adding a field becomes a configuration change rather than a release of your software.

A complete vendor dictionary

json
{
  "inherits": "2.5",
  "segments": { "ZAC": ["SI", "XPN", "DT"] }
}

You can also generate one from a site's own XML Schemas, with hl7-2-from-xsd-into-json-dictionary, which additionally captures cardinality. See Vendor dictionaries.

Message structures and groups

MSH-9 says what the message is: a message code (ORU), a trigger event (R01), and, from v2.3.1, a message-structure id (ORU_R01). The structure is a grammar over segments, and it says which segments group together — an ORU_R01 is a patient result containing an order observation containing observations.

When the segments fit the grammar, these crates nest them: ORU_R01.PATIENT_RESULT.ORDER_OBSERVATION.OBSERVATION. When they do not, the message reads flat instead. It is never an error either way — see degrading rather than rejecting, below.

The HL7 null is not an empty field

HL7 v2 distinguishes “I am not telling you anything about this field” from “delete the value you currently hold”. The second is the explicit null, written as two double quotes.

er7
PID|1||""||SMITH
      ^  ^
      |  the explicit HL7 null: "delete the value you have"
      not sent: "I am not telling you anything about this field"

In an update message the difference is the difference between leaving a patient's address alone and erasing it, so every crate here keeps the two apart, in every format:

text
ER7    ""                  empty field
XML    <PID.2>""</PID.2>   <PID.2/>  or absent
JSON   null                key omitted

Escape sequences

A value that needs to contain a delimiter escapes it. The escape character is whichever MSH-2 declared, conventionally a backslash.

text
\F\   |     the field separator, as a value
\S\   ^     component separator
\T\   &     subcomponent separator
\R\   ~     repetition separator
\E\   \     the escape character itself
\X0A\ hex   an arbitrary byte

\.br\       a formatting command — kept literally, not decoded

The five delimiter escapes and \X..\ hex escapes are decoded on the way in and re-escaped on the way out. Formatting commands such as \.br\ are not decoded — they are kept as literal text — because there is no faithful representation of a line-break instruction in an XML element or a JSON string, and inventing one would lose information the round trip is supposed to preserve.

v2.xml, and the JSON mapping

HL7 also publishes an official XML encoding of v2, namespace urn:hl7-org:v2xml. Every field, component, and subcomponent becomes an element whose name carries its position as the number after the last dot — <PID.5>, and inside it <XPN.1> when the dictionary knows the type or <PID.5.1> when it does not.

That naming rule is why the reverse direction needs no dictionary at all: the position is in the name. It is the single most useful thing to know about these four crates.

There is no official “v2.json”. The JSON mapping here is defined by hl7-2-from-er7-into-json and is designed to preserve everything v2.xml preserves while using idiomatic JSON: real arrays for repetition, real null for the HL7 null, and every scalar as a string, never a number — HL7 numeric text carries leading zeros, explicit signs, and trailing precision that a numeric type would silently destroy.

HL7 v3: the RIM and the envelope

Where v2 is a set of message layouts, v3 is one object model — the Reference Information Model — with six backbone classes (Act, Entity, Role, ActRelationship, Participation, RoleLink) that every domain payload is assembled from.

Every v3 interaction shares a three-level envelope:

text
Message                       level 1 — transport: sender, receiver, id
└── ControlAct                 level 2 — the real-world trigger event
    └── domain: xml::Element    level 3 — the interaction's own payload

hl7-3 implements the RIM backbone, the data types, and that envelope — the part that is the same in every domain. Decoding a specific interaction's payload is left to you, with the RIM types. See the v3 guide.

Transports: MLLP and SOAP

A v2 message carries no length prefix and no self-delimiting syntax, so a receiver reading a TCP socket cannot tell where one message stops and the next begins. MLLP — the Minimal Lower Layer Protocol — is the three-byte answer, and it is the whole protocol:

text
<VT> message <FS><CR>
0x0B          0x1C 0x0D

No length, no checksum, no session, no negotiation, no encryption. MLLP also has no acknowledgement of its own: the reply HL7 expects is an HL7 message, an ACK whose MSA-2 echoes the control ID of the message being answered.

SOAP is the other transport — v2's exception, and v3's norm. Same job, over HTTP, with an envelope and a WSDL. See MLLP and SOAP.

Z-segments, and degrading rather than rejecting

Any segment whose name begins with Z is local by definition: the standard reserves them and says nothing about what is inside. Nearly every real interface carries at least one. The same is true of fields past the end of the published segment, and of data types the dictionary has never seen.

The choice these crates make, everywhere, is to degrade rather than reject:

  • An unknown segment converts, using positional generic names (ZDS.1).
  • A structure with no grammar renders flat, which is still well-formed and lossless.
  • An unknown data type falls back to positional component names.
  • A local Z-segment does not make an otherwise conformant message fail strict mode.

The cost of that choice is a lost typed name, never a lost value and never a rejected message. That is the trade these crates make deliberately, and it is what makes the three-stage workflow in Taming a vendor dialect possible.