HL7 v2 in 2026: Why This 50-Year-Old Protocol Still Drives Healthcare Integration
Most US hospital integrations still ride pipe-delimited messages with custom delimiters per message. Here's why FHIR hasn't displaced it, what an ADT ingest path actually looks like, and a 300-line Go parser that handles the real-world edge cases.
The integration nobody wants to write
If you build clinical software that talks to a US hospital — any hospital, almost any vendor — you’re going to write an HL7 v2 integration. Probably an ADT^A01/A03/A08 feed. Probably as the first thing.
Two things are usually true at this point:
- You assume FHIR will save you. (It won’t, not for ADT.)
- You consider buying a Mirth Connect license or hiring an interface engine specialist. (You can; it’ll work; it’ll also be expensive forever.)
There’s a third option, which is “parse HL7 v2 yourself in ~300 lines of code.” That’s what I just shipped for Bodh, the open-source medical multi-agent platform I’ve been writing in Go. The result is pkg/hl7v2 — a zero-dependency parser with full tests, fixtures for the four most common ADT trigger events, and a POST /hl7/adt endpoint that returns proper AA / AE ACKs.
Here’s why HL7 v2 is still the lingua franca, what the protocol actually requires of you, and what the implementation looks like.
Why FHIR didn’t replace it
FHIR (Fast Healthcare Interoperability Resources) is the modern HL7 standard — JSON over REST, resource-oriented, OAuth 2.0 + OIDC for auth via the SMART on FHIR profile. It’s genuinely good. ONC’s 2015 Edition Cures Update certification requires FHIR R4 with US Core profiles. New healthcare APIs increasingly use it.
But:
- ADT events (admit / discharge / transfer) in most US hospital EHRs are emitted as HL7 v2 because the EHR’s internal interface engine was designed around it 20-30 years ago and hasn’t been rewritten.
- Orders (ORM), results (ORU), scheduling (SIU) — same story. These message types have been stable for decades; the cost to rewrite the EHR’s emitter outweighs the benefit.
- State Medicaid / public health reporting mostly accepts HL7 v2.
- Lab interfaces between hospital labs and outside reference labs almost universally use v2.
What FHIR is replacing:
- Patient-facing APIs (USCDI conformance, patient-mediated record access)
- Population-health bulk exports (FHIR Bulk Data
$export) - New apps launched inside the EHR (SMART App Launch)
- Cross-org clinical document exchange (FHIR Document Reference, increasingly)
What FHIR is not replacing in 2026:
- The 1 AM stream of
ADT^A01messages from the hospital admit / discharge events feed that triggers your TCM workflow. - The
ORU^R01lab result stream that updates your panel-management dashboard. - The
SIU^S12scheduling messages your appointment-reminder engagement layer needs.
If you want to integrate with US hospitals for clinical operations, you write HL7 v2. The question is whether you do it yourself or buy.
What HL7 v2 actually looks like
This is a real ADT^A01 message (admission, anonymised):
MSH|^~\&|ADTSYS|MERCY|BODH|MERCY|20260524143000||ADT^A01|MSG00001|P|2.5
EVN|A01|20260524143000
PID|||MR12345^^^MERCY^MR||REDACTED^REDACTED||19540101|F|||...
PV1||I|MERCY^EDOBS^01|||||||MED||||||...
DG1|1|I10|I50.23^Acute on chronic systolic heart failure^I10|||W
Five segments. Pipe-delimited fields within each segment. The first character of MSH after the segment header is the field delimiter (|). The next four characters are the encoding characters: ^ (component separator), ~ (repetition separator), \ (escape), & (subcomponent separator).
Yes, the delimiters are declared in-band. A message can choose its own delimiters. The parser must read MSH-1 and MSH-2 first, then parse the rest of the message using those delimiters. A library that hardcodes pipes is wrong.
Three real edge cases the parser must handle:
1. Custom delimiters
A poorly-configured hospital sender once decided pipes were annoying and switched to #. Their messages look like:
MSH#@\`\&#ADTSYS#MERCY#BODH#MERCY#...
If your parser hardcodes |, every message from this sender is garbage. The right pattern is:
func parseMSH(line string) (*MSH, error) {
if !strings.HasPrefix(line, "MSH") {
return nil, errors.New("expected MSH segment")
}
fieldSep := rune(line[3]) // MSH-1
encChars := line[4:8] // MSH-2: comp ^ rep ~ esc \ sub &
// ... rest of parser uses these delimiters, not constants
}
Bodh’s parser exercises this in tests: a fixture using #@\\&as the delimiters parses successfully and produces the sameADTEvent` as a fixture using the standard delimiters.
2. Repeating PID-3
PID-3 is “Patient Identifier List”. A patient can have multiple identifiers — internal MRN, account number, SSN (yes, sometimes), driver’s license, military ID. Separated by ~:
PID|||MR12345^^^MERCY^MR~ACC98765^^^MERCY^AN~SSN123456789^^^USA^SS||...
Which one is the patient identifier you care about? The convention: pick the one with assigning-authority type MR (medical record). If none has type MR, fall back to the first repetition.
Bodh’s parser:
func extractPatientID(pid3 string, fieldSep, repSep, compSep rune) string {
for _, rep := range strings.Split(pid3, string(repSep)) {
parts := strings.Split(rep, string(compSep))
// CX type: ID^check_digit^check_alg^assigning_auth^id_type_code
if len(parts) >= 5 && strings.EqualFold(parts[4], "MR") {
return parts[0]
}
}
// Fallback: first repetition's first component
first := strings.SplitN(pid3, string(repSep), 2)[0]
return strings.SplitN(first, string(compSep), 2)[0]
}
Test fixtures include a PID-3 with only AN (account number, not MR) — the fallback path returns the first repetition, with a documented behaviour note.
3. Multiple DG1 segments with priority
DG1 is “Diagnosis.” A discharge message often has multiple DG1 segments — primary diagnosis, secondary, follow-up, admitting. The 6th field is “Diagnosis Type” with values like W (working), F (final), A (admitting), D (discharge).
For mapping to Bodh’s ADTEvent.PrimaryDx, the rule is: working priority W wins, else first DG1. The parser:
func extractPrimaryDx(segments []Segment, /* delimiters */) string {
var firstDG1 string
for _, seg := range segments {
if seg.Name != "DG1" { continue }
dxCode := /* parse DG1-3.1 — code value */
dxType := /* parse DG1-6 */
if dxType == "W" {
return dxCode // working priority wins
}
if firstDG1 == "" {
firstDG1 = dxCode
}
}
return firstDG1 // fallback to first DG1 if no W
}
The ACK problem
Every HL7 v2 message expects an ACK in response. Three ACK codes:
| Code | Meaning |
|---|---|
AA |
Application Accept (parsed + processed successfully) |
AE |
Application Error (parse OK, processing failed — e.g. unknown trigger event) |
AR |
Application Reject (parse failed — message is malformed) |
The ACK is itself an HL7 v2 message with one segment: MSA.
MSH|^~\&|BODH|MERCY|ADTSYS|MERCY|<now>||ACK^A01^ACK|ACK-MSG00001|P|2.5
MSA|AA|MSG00001|
Notice:
- Sender and receiver are flipped from the inbound message (Bodh is now sending; the EHR is now receiving).
- The MSA-2 field echoes the original control ID (
MSG00001) so the sender can correlate. - Trigger event in MSH-9 includes
ACK^A01^ACKso the sender knows which trigger this ACK responds to.
Bodh’s BuildACK constructs this from the parsed inbound MSH:
func BuildACK(in MSH, code string, diagnostic string) string {
var b strings.Builder
b.WriteString("MSH|^~\\&|")
b.WriteString(in.ReceivingApp) // sender becomes receiver
b.WriteString("|")
b.WriteString(in.ReceivingFacility)
b.WriteString("|")
b.WriteString(in.SendingApp) // receiver becomes sender
b.WriteString("|")
b.WriteString(in.SendingFacility)
b.WriteString("|")
b.WriteString(time.Now().UTC().Format("20060102150405"))
b.WriteString("||ACK^")
b.WriteString(in.TriggerEvent)
b.WriteString("^ACK|ACK-")
b.WriteString(in.MessageControlID)
b.WriteString("|P|2.5\r")
b.WriteString("MSA|")
b.WriteString(code)
b.WriteString("|")
b.WriteString(in.MessageControlID)
b.WriteString("|")
b.WriteString(diagnostic)
return b.String()
}
Production add-ons:
- MLLP framing (
\v...\x1c\r) for TCP-based transports — most hospital EHRs use MLLP over TCP, not HTTP. Bodh’s HTTP wrapper is for cloud-native deployments; production over MLLP is a separate transport layer. - Batched messages (FHS / BHS / BTS / FTS envelopes) for overnight batch loads.
- Z-segments — custom segments hospitals invent for vendor-specific data. The parser preserves unknown segments raw so downstream agents can read them if needed.
What a clean Go implementation looks like
Bodh’s parser is ~300 lines. The shape:
package hl7v2
type Message struct {
Segments []Segment
// delimiters parsed from MSH-1/MSH-2
FieldSep rune
CompSep rune
RepSep rune
EscChar rune
SubCompSep rune
}
type Segment struct {
Name string
Fields []string
}
func ParseMessage(raw string) (*Message, error) { /* ... */ }
func (m *Message) FindSegment(name string) *Segment { /* ... */ }
func MessageToADTEvent(m *Message) (medical.ADTEvent, error) { /* ... */ }
func BuildACK(in MSH, code string, diagnostic string) string { /* ... */ }
Five public functions. No external dependencies — just strings, time, errors, fmt.
The cmd/care HTTP handler:
func handleHL7ADT(w http.ResponseWriter, r *http.Request, bus comm.Bus) {
body, _ := io.ReadAll(r.Body)
msg, err := hl7v2.ParseMessage(string(body))
if err != nil {
ack := hl7v2.BuildACK(hl7v2.MSH{}, "AR", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "application/hl7-v2")
w.Write([]byte(ack))
return
}
evt, err := hl7v2.MessageToADTEvent(msg)
if err != nil {
ack := hl7v2.BuildACK(msg.MSH(), "AE", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(ack))
return
}
// Publish ADT event on the bus
bus.Publish(r.Context(), agent.NewMessage(
"user", "readmission_tracker", agent.RoleUser, "adt_event",
medical.EncodeADT(evt),
map[string]any{
"patient_id": evt.PatientID,
"hl7_control_id": msg.MSH().MessageControlID,
},
))
// AA ACK
w.Header().Set("Content-Type", "application/hl7-v2")
w.Write([]byte(hl7v2.BuildACK(msg.MSH(), "AA", "")))
}
The ADT event flows into Bodh’s readmission_tracker agent, which computes the LACE-inspired risk score and publishes readmission_risk + discharge_event to trigger TCM cascades.
What this enables in a clinical workflow
End-to-end, with all the pieces I’ve written about in this series:
- EHR sends
ADT^A01for an admit — pipe-delimited message over MLLP (or in this case, HTTP). - Bodh parses it — extracts patient_id (preferring MR-typed), facility, primary_dx, occurred_at.
- Publishes
adt_eventon the bus — withtenant_idfrom the binary’sBODH_TENANT_IDconfig. - Governance evaluates —
RequireTenantPolicychecks tenant_id present,MaxContentLengthPolicychecks message body size. - Audit event recorded —
KindMessagewith agent_id=readmission_tracker, opaque patient_id, no message body. readmission_trackerruns — computes risk band, emitsreadmission_riskanddischarge_event.discharge_eventis gated by HITL — wraps indischarge_reviewenvelope, addresses tohuman_review.- Reviewer approves —
discharge_reviewaudit event recordsdecision=approve,reviewer_id=rn-rachel, rationale. - TCM cascade triggers —
tcm_coordinatorhandles the discharge, creates nurse tasks, emitstcm_welcome_review. - Reviewer approves the welcome SMS — engagement layer sends the message via Twilio (under BAA).
That’s the full path from an HL7 v2 admit message to a Twilio SMS to the patient, with every step audit-logged, every governance check enforced, every consequential decision human-approved.
The HL7 v2 parser is just the front door. But it’s the most common front door in US hospital integration in 2026.
Three patterns that age well
1. Don’t hardcode delimiters
A library that assumes pipes works 99% of the time, then fails silently when a hospital sender changes config. Parse MSH-1 and MSH-2 first.
2. Be conservative on mapping
For ambiguous fields (multiple PID-3 repetitions, multiple DG1 segments), document the rule you applied and write a test for the fallback. A future contributor adding a new hospital integration needs to know whether the parser picked MR vs first vs nothing.
3. Preserve raw segments
Unknown segments — Z-segments, future-version segments, vendor-specific — should round-trip through the parser unmolested. Bodh stores them in Message.Segments with their raw fields. Downstream agents that know about them can read them; agents that don’t, ignore them.
This is the “be liberal in what you accept” half of Postel’s Law. The other half — “be conservative in what you send” — applies to the ACK builder, which produces a strict subset of v2.5 with no extensions.
Five trade-offs worth eyes-open
1. HL7 v2 in HTTP vs MLLP
Bodh’s /hl7/adt endpoint accepts HTTP POST. Most production hospital EHRs send v2 over MLLP (Minimum Lower Layer Protocol — \v...\x1c\r framing over a persistent TCP socket).
For cloud-native deployments where the EHR can be configured to POST to a URL, HTTP works. For traditional integration engines that only emit MLLP, you need a small TCP listener that strips the MLLP framing and forwards to the HTTP handler (or rewrite the handler to be transport-agnostic). Roughly 100 more lines of code.
2. ORU / ORM / SIU / DFT / MDM coming?
Bodh ships ADT. Each additional message type is a parser extension — same segment grammar, different mapping logic. ORU (results) and ORM (orders) are next most useful.
3. Z-segments are a research project
Hospitals invent custom Z-segments to carry vendor-specific or workflow-specific data that doesn’t fit the standard segments. Mapping Z-segments to a useful domain type usually requires a conversation with the hospital’s integration team. The parser preserves them; the mapping is bespoke.
4. Character set assumptions
HL7 v2 messages can declare a character set (e.g. 8859/1, UNICODE UTF-8) in MSH-18. Bodh’s parser assumes UTF-8 throughout. Production deployments interfacing with older systems (Windows-1252 hospital EHRs are real) need an explicit conversion layer.
5. Escape sequences
The escape character (\ by default) introduces sequences like \F\ (field separator literal), \S\ (component separator literal), \T\ (subcomponent separator literal), \R\ (repetition separator literal), \E\ (escape character literal), \Xnn\ (hex), \Znn\ (Z-segment). Bodh’s parser handles only \F\ / \S\ / \R\ / \E\ / \T\ / \X..\. Production-grade un-escaping is more thorough (especially \X..\ hex sequences); planned addition.
Try it
The parser lives in pkg/hl7v2/. Four test fixtures under pkg/hl7v2/testdata/. The HTTP endpoint in cmd/care/main.go. The mapping to medical.ADTEvent in pkg/hl7v2/hl7v2.go.
git clone https://github.com/PratikDhanave/bodh.git
cd bodh
# Run the parser tests
go test ./pkg/hl7v2/... -v
# Start the HTTP server and POST a real fixture
go run ./cmd/care -addr=:8088 &
curl -X POST localhost:8088/hl7/adt \
-H 'Content-Type: application/hl7-v2' \
--data-binary @pkg/hl7v2/testdata/adt_a01_admit.hl7
# Returns: MSH|^~\&|BODH|MERCY|ADTSYS|MERCY|...|ACK^A01^ACK|ACK-MSG00001|P|2.5
# MSA|AA|MSG00001|
# Inspect the audit event the parsed message produced
curl -s localhost:8088/audit?kind=message | jq '.[] | select(.type=="adt_event")'
The full HL7 v2 reference is in docs/api.md. Healthcare standards context (where HL7 v2 sits relative to FHIR, X12, NCPDP, DICOM, CDA) is in docs/healthcare-standards.md.
Repo: github.com/PratikDhanave/bodh
If you’re writing hospital integrations and want to compare parser approaches, mapping conventions, or the FHIR-vs-v2 decision matrix — issues, PRs, and DMs welcome.
Bodh is a research and engineering reference. Not certified for production hospital integration without independent validation, BAA coverage, and operational tooling I have not built. HL7 v2 in production deployments requires interface-engine-grade reliability, dead-letter queues, replay capabilities, and 24×7 ops support.