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:

  1. You assume FHIR will save you. (It won’t, not for ADT.)
  2. 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:

What FHIR is replacing:

What FHIR is not replacing in 2026:

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:

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:


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:

  1. EHR sends ADT^A01 for an admit — pipe-delimited message over MLLP (or in this case, HTTP).
  2. Bodh parses it — extracts patient_id (preferring MR-typed), facility, primary_dx, occurred_at.
  3. Publishes adt_event on the bus — with tenant_id from the binary’s BODH_TENANT_ID config.
  4. Governance evaluatesRequireTenantPolicy checks tenant_id present, MaxContentLengthPolicy checks message body size.
  5. Audit event recordedKindMessage with agent_id=readmission_tracker, opaque patient_id, no message body.
  6. readmission_tracker runs — computes risk band, emits readmission_risk and discharge_event.
  7. discharge_event is gated by HITL — wraps in discharge_review envelope, addresses to human_review.
  8. Reviewer approvesdischarge_review audit event records decision=approve, reviewer_id=rn-rachel, rationale.
  9. TCM cascade triggerstcm_coordinator handles the discharge, creates nurse tasks, emits tcm_welcome_review.
  10. 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.

HL7 #FHIR #HealthcareIT #Integration #Go #Golang #OpenSource #HealthTech #InterfaceEngine #ClinicalSoftware