Crates

hl7-2

HL7 v2 itself: parse, navigate, validate, modify, render

Core v0.2.3 CLI: hl7-v2 Specified

Install cargo add hl7-2
Rust path hl7_2
Dependencies er7
Links crates.io docs.rs source spec

Cargo features

Feature Default Effect
derive off Adds `#[derive(FromHl7)]` and `#[derive(ToHl7)]`; pulls in `hl7-2-derive`.

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

sh
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-v2

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

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

rust
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

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

sh
$ 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

rust
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 send
rust
let 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.

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

rust
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

sh
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-emit

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