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.
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.
$ 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 = EVENotice 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.
$ hl7-v2 --query OBX-5 oru_r01.hl7
187
D
$ hl7-v2 --paths oru_r01.hl7 | head -3
$ hl7-v2 --check oru_r01.hl7
ok2. Parse it
cargo new hl7-hello
cd hl7-hello
cargo add hl7src/main.rs
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.
// 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
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:
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:
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 sendAn 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:
$ 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-er7The 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.