Crates
hl7-2
HL7 v2 itself: parse, navigate, validate, modify, render
What it is
Releases 2.1 through 2.9, in three modes that share one set of internals — generic for the vendor you have never seen, schema-based for the vendor whose format is not frozen, struct-based for the feed that does not change. Also a command-line tool. This is what everything else in the workspace builds on.
HL7 v2 is the format most healthcare data still moves in, and the hard part is not the syntax.
That is pipes and carets, and the er7 crate this one is built on already handles it. The hard part is knowing what the pipes and
carets mean in the release the sender speaks. This crate owns that: the per-release
data-type tables, the message structures, and the three ways to apply them.
Cargo features
cargo add hl7-2 # library
cargo add hl7-2 --features derive # library with the derive macros
cargo install hl7-2 # command-line tool, named hl7-v2Three modes
Generic — for the vendor you have never seen
Parse anything into a navigable tree. Nothing is rejected, nothing is dropped, and what the dictionary recognises gets a name from HL7's own vocabulary.
let message = hl7_2::parse(text)?;
let tree = message.tree();
assert_eq!(tree.name(), "ORU_R01");
assert_eq!(tree.find("XPN.1").unwrap().text(), "EVERYWOMAN");
// Every node knows the path that reads it back.
let second = tree.find_all("OBX").nth(1).unwrap();
assert_eq!(second.path(), "OBX[2]");
assert_eq!(message.get("OBX[2]-5.2")?.as_deref(), Some("Rh positive"));Segments are grouped into the message structure when they fit it — ORU_R01.PATIENT_RESULT.ORDER_OBSERVATION.OBSERVATION — and read flat when they do
not.
Schema-based — for the vendor whose format is not frozen
Write the shape as JSON, load it at runtime, and adding a field is a configuration change rather than a release.
let dictionary = hl7_2::Dictionary::from_json(r#"{
"inherits": "2.5",
"segments": { "ZAC": ["SI", "XPN", "DT"] }
}"#, "acme")?;
let options = hl7_2::Options::new().with_dictionary(std::sync::Arc::new(dictionary));
let message = hl7_2::parse_with_options(text, &options)?;
// The vendor's own segment now reads like any standard one.
assert_eq!(message.tree().find("XPN.2").unwrap().text(), "JOHN");The same format describes the bundled releases, so a schema can inherit one and state only its dialect. See Vendor dictionaries.
Struct-based — for the feed that does not change
use hl7_2::{FromHl7, Raw};
#[derive(FromHl7)]
struct Admission {
#[hl7("PID-3.1")] patient_id: String,
#[hl7("PID-7.1")] birth_date: Option<String>,
#[hl7("PID-3")] all_identifiers: Vec<String>,
#[hl7(raw)] raw: Raw,
}
let admission: Admission = hl7_2::parse(text)?.decode()?;
assert_eq!(admission.patient_id, "241900");
// The one vendor field no struct models — same object, no second parse.
assert_eq!(admission.raw.get("ZPD-1")?.as_deref(), Some("local"));That last field is the point. Real feeds are stable until they are not, and the usual choice at
that moment is to re-parse the raw message or rewrite the library. A Raw field keeps
the whole parsed message beside the typed data, so the fallback is a method call.
The walkthrough the modes were designed for
The three modes are not three libraries to choose between. They are three stages of the same job, and a real integration walks through them in order.
Stage 1 — look at it. A vendor sends a message and nobody knows what is in it. Start with the tool, not with code:
$ hl7-v2 --paths samples/vendor.hl7
ADT_A01
ZAC [ZAC[1]]
ZAC.1 = 7 [ZAC[1]-1[1]]
ZAC.2 [ZAC[1]-2[1]]
ZAC.2.1 = SMITH [ZAC[1]-2[1].1]
ZAC.2.2 = JOHN [ZAC[1]-2[1].2]
ZAC.3 = 20260814 [ZAC[1]-3[1]]Everything standard is already named, and the vendor's own ZAC is there
positionally, nothing lost. The bracketed paths are not decoration: each one is what reads that
value back.
Stage 2 — write down what you learned. ZAC.2 is clearly a name. Say
so, in JSON, without touching the code.
Stage 3 — freeze what is stable. Once the interface has held still long enough
to trust, move it into the type system — and keep the raw field, because stage 3 is
never final.
The full walkthrough is Taming a vendor dialect.
Modify and build
let mut message = hl7_2::parse(text)?;
message.set("PID-5.2", "EVELYN")?; // escapes delimiters in the value
message.append_segment("NTE");
message.set("NTE[2]-3", "Amended.")?;
let er7 = message.to_er7(); // valid ER7, ready to sendlet ack = hl7_2::builder::acknowledge(&message, "AA", "ACK00001", "20260814080100")
.build_valid()?;
assert_eq!(ack.get("MSA-2")?.as_deref(), Some("MSG00042"));An unmodified message writes back byte for byte — that guarantee is er7's, and this
crate does not weaken it.
Validate
Parsing stays lenient: unknown segments, unknown types, and structure mismatches are never errors. Checking is a separate question with a separate answer.
for diagnostic in message.validate() {
println!("{diagnostic}");
// error: MSA[1]-4[1]: "many" is not a valid NM value
}Diagnostics split by whose problem it is. Errors are the message contradicting the dictionary it claims. Warnings are the dictionary not covering the message. Strict mode rejects the first and allows the second:
let options = hl7_2::Options::new().strict();
match hl7_2::parse_with_options(text, &options) {
Err(hl7_2::Error::Invalid(diagnostics)) => { /* every error-level finding */ }
Ok(message) => { /* conformant */ }
Err(other) => { /* not a message at all */ }
}Versions
The fourteen published releases from 2.1 to 2.9, chosen from MSH-12 or forced with Options::with_version. A release string this crate has no dictionary for resolves to
the nearest older one (2.5.2 reads as 2.5.1) rather than failing.
v2.5 is the complete base dictionary; every other release is a delta of it covering the differences this crate models today, and inherits the rest. That incompleteness is bounded by design: an unmodelled difference costs a typed name, never a rejected message or a lost value. See Versions and compatibility.
Command line
hl7-v2 samples/oru_r01.hl7 # look at it
hl7-v2 --query OBX-5 samples/oru_r01.hl7 # pull out every result
hl7-v2 --check samples/adt_a01.hl7 # check it
hl7-v2 --dictionary samples/acme.json vendor.hl7 # read a dialect
hl7-v2 --set 'PID-8=F' --er7 samples/orm_o01.hl7 # edit and re-emithl7-v2 --help lists everything. Exit status is 0 on success, 1 on a usage or parse
error, and 2 when --check or --strict found something wrong. Full
reference: Command line.
Dependencies
One: er7, which has none of its own. The
JSON reader that loads dictionaries is hand-written here for the same reason the sibling crates
hand-write their writers — in a domain where dependency trees get audited, a two-crate tree is
worth a few hundred lines. Enabling the derive feature adds hl7-2-derive, and with it syn and quote.
A note on the name
The crate is published as hl7-2; the binary it installs is called hl7-v2. The package could not take the readable name because hl7-v2 on crates.io is an unrelated crate, and the rest of the workspace was renamed
from hl7-v2* to hl7-2* for consistency with it. The archived
repositories those directories came from still carry the original names.
Related crates
hl7
The umbrella crate: one module per HL7 standard
hl7-2-derive
#[derive(FromHl7)] and #[derive(ToHl7)] for struct mode
hl7-2-mllp
MLLP: HL7 v2 framed on a TCP stream
hl7-2-from-er7-into-xml
ER7 → the official v2.xml XML representation