hl7probe
A friendly command-line tool for reading and checking HL7 v2 messages — and a Rust library that does the same from your own code.
Hospital systems talk to each other in HL7 v2 — dense lines of text full of pipes and carets that look like this:
PID|1||123456^^^MERCY^MR||Smith^John^A^^Mr||19850312|M
Reading that by hand is slow and error-prone. hl7probe turns those lines into
something a person can read, and tells you what a receiving hospital system
would reject.
Contents
- Why you'd use it
- Install
- Quick start
- Reading the output
- Using it as a library
- Interactive viewer
- Using it in scripts and CI
- All the options
- What it checks
- What it accepts
- Performance
- Testing
- Building from source
- Contributing
- License
Why you'd use it
You are wiring up an interface between two health systems and a message is being rejected. You need to know what is in the message and what is wrong with it — quickly, without opening a heavyweight integration engine.
hl7probe answers both in one command:
- Every field is shown with its real name —
PID-5becomes Patient Name. - Codes are translated —
Mbecomes Male,Ibecomes Inpatient. - Dates become readable —
19850312becomes 1985-03-12, age 41. - Problems are listed with the exact field, the line number and why it matters.
No configuration, no database, no server. One binary, one file, one answer.
Install
Homebrew (macOS and Linux)
Recent Homebrew versions ask you to trust a third-party tap the first time; if
you see that prompt, run brew trust sudhi001/tap and install again.
Download a prebuilt binary
Grab the archive for your platform from the
releases page, unpack it and
put hl7probe somewhere on your PATH. The Linux builds are static, so they
run on any distribution regardless of its glibc version:
On Windows, download the x86_64-pc-windows-msvc.zip archive, unpack it and put
hl7probe.exe in a folder on your PATH:
Expand-Archive hl7probe-*-x86_64-pc-windows-msvc.zip -DestinationPath .
With Cargo
Check it works:
$ hl7probe --version
hl7probe 0.10.0
None of the commands above pin a version: they each fetch the current release, which the badges at the top of this page track.
Quick start
Point it at a message file:
Or pipe one in:
|
Try it on the samples that ship with the project:
Reading the output
The report has three parts.
1. What the message is. The HL7 version, the message type, a plain-English description, who sent it and when.
HL7 v2.5.1 ADT^A01 Admit / Visit Notification
MSG00001 · 2024-01-15 14:32:00 · HIS/MERCY → LIS/LAB · Production
2. What is inside it. Each segment is listed with a status mark, then each
field is shown with its name, its raw value, and — after the › — the same
value in plain language.
Segments
────────────────────────────────────────────
MSH ✓ Message Header
EVN ✓ Event Type
PID ✓ Patient Identification
PV1 ✓ Patient Visit
PID · Patient Identification line 3
──────────────────────────────────────────────────────────────
3 Patient Identifier List 123456^^^MERCY^MR › 123456 (MR, MERCY)
~ rep 2 987654321^^^SSA^SS › 987654321 (SS, SSA)
5 Patient Name Smith^John^A^^Mr › Mr John A Smith
7 Date/Time of Birth 19850312 › 1985-03-12, age 41
8 Administrative Sex M › Male
⚠ 11 Patient Address (empty) recommended
3. What is wrong with it. Five groups of checks, then the individual findings, each pointing at the field responsible.
Validation
────────────────────────────────────────────
✗ Structure ADT^A01
⚠ Required fields
✗ Data types
⚠ Code tables
✗ Consistency
✗ EVN required segment is missing — ADT^A01 requires EVN (Event Type)
✗ PID-7 not a valid date/time — day 32 does not exist in 1985-03
✗ PV1-3 invalid location — component 1 (point of care) is empty
⚠ PID-11 missing — Patient Address should be populated when the value is known
5 errors · 8 warnings
The three marks mean:
| Mark | Meaning |
|---|---|
| ✓ | Fine |
| ⚠ | Works, but a receiving system may complain — a missing recommended field, an unusual code |
| ✗ | Wrong — this will be rejected |
Using it as a library
The parser and validator are a library as well as a command, so the same checks can run inside your own code:
use ;
let text = read_to_string?;
let message = parse?;
println!;
// PID-5.1 is the patient's family name.
let pid = message.first.expect;
println!;
let report = validate;
for finding in &report.findings
A message borrows the text it was read from rather than copying it, so keep
that string alive for as long as you use the message. For a file holding
several messages, walk them with parser::split_messages:
let = split_messages;
for raw in &raws
The four public modules are parser (decomposition), validate (the checks
and their findings), spec (the HL7 dictionary the checks read) and
datetime (HL7 timestamp handling). Full API documentation is on
docs.rs.
Interactive viewer
For bigger messages, browse instead of scroll:
Segments on the left, decoded fields on the right, problems underneath. Move
with the arrow keys, press ? for help and q to quit.
| Key | Action |
|---|---|
↑ ↓ or j k |
Move within the focused panel |
← → or h l |
Jump between segments and fields |
tab |
Cycle segments → fields → validation |
n / p |
Next / previous message in the file |
a |
Also show fields that were left empty |
v |
Include informational notes |
r |
Show the raw segment line |
f |
Findings for this segment only, or the whole message |
? |
Help |
q or esc |
Quit |
Using it in scripts and CI
One line per message, ideal for checking a folder full of test messages:
Exit codes make it a gate in a build pipeline:
| Code | Meaning |
|---|---|
0 |
No errors (warnings are allowed unless you pass --strict) |
1 |
At least one validation error |
2 |
The input could not be read, or contained no HL7 message |
||
Pull out a single value without writing a parser:
HL7 escape sequences are already decoded, so the output drops straight into a shell script.
Machine-readable reports for dashboards and tests:
|
All the options
hl7probe [OPTIONS] [FILE]...
FILE can be given more than once. Use -, or no file at all, to read from
standard input.
| Option | What it does |
|---|---|
-t, --tui |
Open the interactive viewer |
--json |
Print the whole report as JSON |
-q, --quiet |
Print one verdict line per message |
-v, --verbose |
Include informational notes |
-a, --all |
Show fields that were left empty |
-s, --segment PID,PV1 |
Only show these segments in detail |
--summary |
Segment list and verdict only, no field tables |
--raw |
Print the original segment line above each table |
-f, --field PID-5.1 |
Print one value and nothing else |
-m, --message N |
Only inspect the Nth message in the file |
--strict |
Count warnings as failures in the exit code |
--color auto|always|never |
Colour control (NO_COLOR is respected) |
--width N |
Wrap at N columns instead of the terminal width |
-h, --help |
Full help |
What it checks
Structure — the message type in MSH-9 is matched against the official
message layout (ADT, ORU, ORM/OML, ACK, SIU, MDM, VXU, DFT, BAR, RDE, QRY and
others). A missing required segment is an error; an unexpected or out-of-order
segment is a warning. Site-specific Z segments are left alone.
Required fields — fields the standard marks as required are errors when absent. Fields that should be filled in whenever the value is known — patient address, visit number, observation time — are warnings.
Data types — dates and times are checked against the real calendar, so
19850332 and 20230229 are caught, along with bad timezone offsets,
non-numeric numbers, identifiers with no ID, and locations with no ward.
Code tables — coded values are looked up in their HL7 table, so Q in the
patient class field is flagged. Unknown codes are warnings, because local code
sets are normal; tables that allow no local values — processing ID, version,
acknowledgement code, yes/no — are errors.
Consistency — the cross-field rules that catch real interface bugs:
- the event code in
EVN-1disagreeing with the trigger inMSH-9 - a discharge time earlier than the admission time
- a date of birth in the future, or an implausible age
- a discharge message with no discharge time
- an inpatient with no assigned location
- an observation value that contradicts its declared type (
NMholding text) - set IDs on repeating segments that do not count up
- the same patient identifier repeated twice
- accented characters sent with no character set declared in
MSH-18 - a message control ID too long for the receiving system
Every finding carries a severity, the exact field, the line number in the file and an explanation of why it matters.
How much of HL7 the dictionary covers
126 segments carry field-by-field definitions — every field HL7 2.5.1 gives them, 1,928 in total — and 165 code tables hold 2,461 values. The other five versions are carried on top of that baseline as 1,659 recorded differences.
HL7 2.5.1 publishes 149 segments; the 23 that are not here are the query,
equipment and laboratory-automation segments (EQU, ECD, SAC, TCD and
the like), and a message carrying one is reported as carrying a segment this
build does not know. Four segments from other versions — ARV, OBS, ORO,
UAC — are recognised by name, with no field definitions behind them.
The definitions are parsed from the attribute tables the standard publishes and the code values from HL7's own terminology, rather than transcribed by hand, and a test fails if a field — in the baseline or in any version's differences — ever names a table the dictionary cannot supply. A further 121 tables are recorded as deliberately unchecked, each with the reason: most are the site-defined tables HL7 publishes no values for, and the rest are numbers HL7 has since reused for something else, where checking an older message against the current list would report every value in it as wrong.
What is not checked
Free-text fields (ST, TX, FT) have nothing to check beyond their
presence. Coded fields (ID, IS) are checked against the HL7 table they name
and not otherwise, since without a table any value is legal. OBX-5 carries
whatever type OBX-2 declares and is checked against that.
Beyond those, several composite types are read and displayed but their
components are not individually validated: addresses (XAD), phone numbers
(XTN), organisation names (XON), and the timing, price and quantity types
(TQ, CP, CQ). A malformed phone number will be shown, not reported.
Which HL7 version the checks come from
A message is read and checked against the version it declares in MSH-12. Field
numbering, names, usage and code tables all move between versions of HL7 v2, so
the same message can be correct under one and wrong under another: PID-39
arrived in 2.5, and sending it in a message that declares 2.4 is a real error.
The version decides what a field is called, too — PID-3 is one internal
patient ID in 2.3 and a list of identifiers in 2.5.1, and the report says
whichever the message asked for.
Definitions are carried for 2.3, 2.3.1, 2.4, 2.5, 2.5.1 and 2.6. Messages declaring any other version are still read and checked, against 2.5.1, and the report says so rather than leaving it implicit:
ℹ MSH-12 checked against HL7 2.5.1 — this build carries no field definitions
for 2.7, so the 2.5.1 ones were used; field numbering, usage and code tables
differ between versions, and some findings may not apply
Four segments in 2.3 — OM1, OM3, OM5 and ORC — fall back to the 2.5.1
definitions, because the rows of their attribute tables in the published 2.3
document do not line up with their columns and reading them wrong would be
worse than not reading them. HL7 2.2 and earlier are not carried at all: the
published 2.2 tables drop the blank cells out of the usage, repeat and table
columns, so which field each value belongs to cannot be recovered.
spec::Dictionary::for_version exposes this to callers, and
spec::DICTIONARY_VERSION names the baseline the others are recorded against.
What it accepts
Real-world message files are messy. hl7probe copes with:
- Windows, Unix or classic Mac line endings (
CRLF,LF,CR) - MLLP framing bytes left over from a network capture
- Batch files with
FHS/BHS/BTS/FTSwrappers - Several messages in one file, each reported separately
- Non-UTF-8 (latin-1) text, decoded instead of rejected
- Custom delimiters — whatever
MSH-1andMSH-2declare is what is used
Performance
A batch file is read into memory once and never copied. Messages are parsed, written and dropped one at a time, and a parsed message is a set of views over the file text rather than an owned tree, so peak memory is close to the size of the input regardless of which output mode you ask for.
Measured on an Apple M4 (macOS 26.6, rustc 1.98.0, --release), best of seven
runs with output discarded, against a file of 50,000 copies of
examples/adt_a01.hl7 — 26 MB, or about 300,000 segments:
| Command | Time | Peak memory |
|---|---|---|
hl7probe -q (validate only) |
0.77s | 32 MB |
hl7probe -f PID-5.1 (one field per message) |
0.02s | 31 MB |
hl7probe (full report) |
2.96s | 32 MB |
hl7probe --json |
4.49s | 32 MB |
-f is the fastest because it is the only mode that never validates: it finds
one field per message and writes it.
That is about 1.2x the file, and most of it is the file. At the sizes most runs actually are, none of this matters: a single message reports in about 4 ms end to end — most of that being process startup — using 2.7 MB, and 5,000 messages (2.6 MB) validate in 0.08s using 5.8 MB.
Memory used to scale with the batch rather than the message, because every
message was parsed up front and every field, component and subcomponent became
its own String:
| Command | Peak memory before | After |
|---|---|---|
hl7probe -q |
2,234 MB | 33 MB |
hl7probe -f PID-5.1 |
2,234 MB | 33 MB |
hl7probe |
2,417 MB | 34 MB |
hl7probe --json |
4,598 MB | 34 MB |
A parsed message costs 2.4 KB and 35 allocations, down from 40 KB and 482. That is what the interactive viewer pays per message, since it has to hold the whole batch to let you page back and forth. Validating one costs a further 19 allocations when it is clean, and beyond that only what its findings need.
To reproduce:
Testing
169 tests: 136 unit tests beside the code they cover, 29 that run the built binary the way you would, 3 that hold the parser to its properties over a large body of damaged input, and a doctest that compiles and runs the example in the library documentation, so it cannot drift from the API.
The property tests are the ones that earn their keep on real traffic. Every truncation of every example message, plus thousands of randomly corrupted copies, are put through the parser to establish that nothing can make it panic, and that each segment it decodes still rejoins to the exact line it came from — a decoder that quietly loses a character is worse than one that refuses the message. The corpus is generated from a fixed seed, so a failure is reproducible rather than a matter of luck.
Line coverage is 95%, enforced in CI by a floor that fails the build if it drops. To reproduce:
What the remaining 5% is, and why it stays there:
| Uncovered | Lines | Why |
|---|---|---|
Terminal driving in --tui |
57 | run, setup, restore and the event loop need a real terminal in raw mode. Covering them means a pty harness, which buys flakiness rather than confidence — the drawing they call is tested against a fake backend. |
| Everything else | ~158 | Error arms for conditions the tests cannot force (a closed stdout mid-write, stdin attached to a terminal), and const fns evaluated at compile time, which no runtime test can reach. |
The interactive viewer's rendering is tested: ratatui's TestBackend draws
each panel into an in-memory buffer that the tests read back, so what appears on
screen is checked without needing a screen.
Building from source
Requires Rust 1.88 or newer.
The code is organised as:
| File | Responsibility |
|---|---|
src/parser.rs |
Splitting messages into segments, fields, components |
src/spec.rs |
The HL7 dictionary: field names, code tables, message layouts |
src/validate.rs |
The checks, one rule per concern |
src/view.rs |
The decoded field model both output modes share |
src/render.rs |
The printed report |
src/tui.rs |
The interactive viewer |
src/datetime.rs |
HL7 date and time handling |
src/text.rs |
Padding and truncation helpers |
src/main.rs |
Command-line interface |
Adding a validation check means writing one Rule implementation in
src/validate.rs and listing it in RULES; nothing else changes.
Changelog
Release notes live in CHANGELOG.md.
Contributing
Issues and pull requests are welcome. Please make sure cargo test and
cargo clippy --all-targets pass, and add a test alongside any behaviour
change — the fastest way to describe an HL7 bug is a message that reproduces it.
Note your change under Unreleased in CHANGELOG.md.
License
MIT — see LICENSE.