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::fhirfree 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:
MSH|^~\&|LAB|ACME|EHR|CLINIC|20260814080000||ORU^R01|MSG00042|P|2.5MSH-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.
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.1means segmentPID, field 5, component 1. Paths are whatget,set, and the CLI's--querytake, and they are what the derive macros' attributes hold. - A name is a description:
XPN.1means 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.
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 backThe 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
{
"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.
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:
ER7 "" empty field
XML <PID.2>""</PID.2> <PID.2/> or absent
JSON null key omittedEscape sequences
A value that needs to contain a delimiter escapes it. The escape character is whichever MSH-2 declared, conventionally a backslash.
\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 decodedThe 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:
Message level 1 — transport: sender, receiver, id
└── ControlAct level 2 — the real-world trigger event
└── domain: xml::Element level 3 — the interaction's own payloadhl7-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:
<VT> message <FS><CR>
0x0B 0x1C 0x0DNo 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.