Documentation

Quick start

A real lab result, parsed, queried, validated, answered, and converted — from an empty directory to a working acknowledgement in about five minutes.

The message

Save this as oru_r01.hl7. It is an ORU^R01 — an unsolicited observation result, the message a lab sends when it has an answer. Segment terminators are carriage returns in the real thing; a text editor's newlines are fine here, because the parser accepts either.

er7
MSH|^~\&|LAB|ACME|EHR|CLINIC|20260814080000||ORU^R01|MSG00042|P|2.5
PID|1||444333222^^^ACME&1.2.3.4&ISO^MR||EVERYWOMAN^EVE^E||19620320|F|||2222 HOME ST^^ANN ARBOR^MI^48104
PV1|1|O|OP^^^ACME
ORC|RE|ORD776655
OBR|1|ORD776655|LAB2233|24331-1^Lipid Panel^LN|||20260813071500
OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL|<200|N|||F
OBX|2|CE|10331-7^Rh Type^LN||D^Rh positive^LN|||N|||F
NTE|1||Fasting sample.

Read left to right, MSH is the header that carries the delimiters and says what the message is; PID identifies the patient; OBR is the order the result answers; each OBX is one observation; NTE is a note.

1. Look at it, without writing code

Start with the tool, not with a project. The first question about any message is “what is actually in here”, and the tree answers it.

sh
$ cargo install hl7-2
$ hl7-v2 oru_r01.hl7
ORU_R01
  MSH
    MSH.1 = |
    MSH.2 = ^~\&
    MSH.3
      HD.1 = LAB
    ...
  ORU_R01.PATIENT_RESULT
    ORU_R01.PATIENT
      PID
        PID.1 = 1
        PID.5
          XPN.1
            FN.1 = EVERYWOMAN
          XPN.2 = EVE

Notice what the dictionary did: PID.5 broke into XPN.1 and XPN.2, because HL7 v2.5 says PID-5 holds an XPN — an extended person name. Nothing about that is hardcoded per field; it comes from the release the header claimed.

sh
$ hl7-v2 --query OBX-5 oru_r01.hl7
187
D

$ hl7-v2 --paths oru_r01.hl7 | head -3
$ hl7-v2 --check oru_r01.hl7
ok

2. Parse it

sh
cargo new hl7-hello
cd hl7-hello
cargo add hl7

src/main.rs

rust
use hl7::v2;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string("oru_r01.hl7")?;
    let message = v2::parse(&text)?;

    // MSH-9 said ORU^R01, so the dictionary knows the structure.
    assert_eq!(message.structure_id(), "ORU_R01");
    Ok(())
}

parse is lenient by design. An unknown segment, an unknown data type, or a structure that does not match its declared grammar is never an error — it degrades to a positional name or a flat layout. Checking is a separate question with a separate answer.

3. Read values out of it

There are two vocabularies here, and keeping them apart saves a lot of confusion.

rust
// A path addresses a value. This is what --query takes, and what set takes.
assert_eq!(message.get("PID-5.1")?.as_deref(), Some("EVERYWOMAN"));
assert_eq!(message.get("OBX[2]-5.2")?.as_deref(), Some("Rh positive"));

// The tree names a value, using HL7's own vocabulary.
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_obx = tree.find_all("OBX").nth(1).unwrap();
assert_eq!(second_obx.path(), "OBX[2]");

4. Check it

rust
for diagnostic in message.validate() {
    println!("{diagnostic}");
    // error: MSA[1]-4[1]: "many" is not a valid NM value
}

// Or refuse anything that does not conform, up front:
let options = v2::Options::new().strict();
match v2::parse_with_options(&text, &options) {
    Ok(message) => { /* conformant */ }
    Err(v2::Error::Invalid(diagnostics)) => { /* every error-level finding */ }
    Err(other) => { /* not a message at all */ }
}

Diagnostics split by whose problem it is. Errors are the message contradicting the dictionary it claims — a required segment missing, a numeric field holding letters. Warnings are the dictionary not covering the message — an unknown segment, a structure with no grammar yet. Strict mode rejects the first and allows the second, so a local Z-segment does not make an otherwise conformant message fail.

5. Answer it

A system that reads HL7 usually has to reply in it. First, editing:

rust
let mut message = v2::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();

Then the acknowledgement, which is what an ORU^R01 sender is waiting for:

rust
let ack = v2::builder::acknowledge(&message, "AA", "ACK00001", "20260814080100")
    .build_valid()?;

// MSA-2 echoes the control ID of the message being answered. That echo is
// the whole mechanism: it is what says *which* message this answers.
assert_eq!(ack.get("MSA-2")?.as_deref(), Some("MSG00042"));

let er7 = ack.to_er7();   // valid ER7, ready to send

An unmodified message writes back byte for byte — that guarantee comes from the er7 layer, and hl7-2 does not weaken it. To put that acknowledgement on a socket, see MLLP over TCP.

6. Convert it

If what you actually need is the message in a format something downstream can read, skip the library entirely:

sh
$ hl7-2-from-er7-into-xml oru_r01.hl7 > oru_r01.xml
$ hl7-2-from-er7-into-json oru_r01.hl7 > oru_r01.json

# And back again — the round trip is the smoke test
$ hl7-2-from-er7-into-xml oru_r01.hl7 | hl7-2-from-xml-into-er7

The last line is the round trip, and it is the smoke test the crates use on themselves: the output is the original ER7 message, canonicalized. See A lossless round trip.

Where to go next

  • Concepts — ER7, v2.xml, the dictionary, names versus paths, and why the HL7 null is not the same as an empty field.
  • Taming a vendor dialect — what to do when the message has segments the standard has never heard of. This is the common case, not the exception.
  • The guides — one page per task, each with the API calls that do it.