DEV Community

Cover image for HL7 v2 Looks Easy to Parse. Here's What Breaks First.
IntelliSource Technologies
IntelliSource Technologies

Posted on

HL7 v2 Looks Easy to Parse. Here's What Breaks First.

The first HL7 v2 message you receive will make you overconfident.
It's plain text. Fields separated by pipes. You write line.split("|"), pull out the patient name, and the demo works. Then real traffic arrives from a real hospital, and over the next two weeks you learn everything the sample message didn't show you.

Here's the message we'll work with. Fictional patient, realistic structure:
MSH|^~&|EPIC|MERCYHOSP|INTAKE|CLINICAPP|20260915083012-0400||ADT^A01^ADT_A01|MSG00412|P|2.5.1
PID|1||22-5190^^^OUTPTLAB^MR~4471190^^^MERCY^MR||CHEN^SARAH^L||19840312|F|||12 PALM ST^^MIAMI^FL^33180
NTE|1||Pt prefers Spanish \T\ English interpreter
PV1|1|I|4WEST^412^B||||1234^RIVERA^ANA

That's an ADT^A01, a patient admission, in four segments: the header, patient identity, a note, and the visit. Every problem below is something this message is quietly doing.

1. Segments end in \r, not \n

HL7 v2 terminates segments with a carriage return. Not a newline, not CRLF. Just \r.

Your editor and most log viewers render it as a line break, so the message looks line-delimited when you inspect it. Then raw.split("\n") returns one giant string, and you spend an afternoon wondering why your parser only ever sees an MSH segment.

segments = [s for s in raw.split("\r") if s]

Some senders get this wrong in the other direction and send \n or \r\n. Normalize line endings once, at the edge, and log when you had to. That log line will tell you something about the sender you'll want to know later.

  1. MSH is off by one This is the one that gets everyone. In every other segment, field 1 is the first thing after the segment name. In MSH, field 1 is the field separator itself. MSH-1 is |. So when you split the MSH line on |, MSH-1 vanishes and every index after it shifts left by one.

Split naively and parts[9] hands you MSG00412, the control ID, when you wanted the message type. Put the separator back:

def fields(segment):
parts = segment.split(FIELD)
if parts[0] == "MSH":
parts.insert(1, FIELD) # MSH-1 is the separator itself; restore it
return parts

Now fields(msh)[9] is MSH-9, ADT^A01^ADT_A01, and index numbers match the spec in every segment. Code review against the spec PDF gets a lot less painful.

  1. Read the separators from the message

MSH-2 declares the encoding characters: ^~&. Component separator, repetition separator, escape character, subcomponent separator, in that order.

Nearly everyone uses those defaults. Read them anyway. It costs one line and removes a class of bug you'd otherwise meet in production:

msh = segments[0]
FIELD = msh[3]
COMP, REP, ESC, SUB = msh[4:8]

  1. Repeating fields will hand you the wrong patient ID

PID-3 is the patient identifier list, and the word "list" is doing real work. Our message carries two identifiers separated by ~: one from an outpatient lab, one from the hospital.

The naive approach takes the first:

pid[3].split(REP)[0].split(COMP)[0] # -> '22-5190'

That's the outpatient lab's number, not the hospital's medical record number. Nothing errors. You just store the wrong ID and find out when records stop lining up.

Each repetition carries its own components. PID-3.4 is the assigning authority, and PID-3.5 is the identifier type (MR for medical record number). Select on those:

def mrn_for(pid_fields, authority):
for rep in pid_fields[3].split(REP):
c = rep.split(COMP)
if len(c) > 4 and c[3] == authority and c[4] == "MR":
return c[0]
return None

mrn_for(pid, "MERCY") # -> '4471190'

Which authority you want is a question for the site, not the spec. Ask it during interface analysis, and get the answer in writing.

  1. Unescape last, not first

What happens when the data itself contains a separator? HL7 escapes it. \T\ is a literal &, \S\ a literal ^, \F\ a literal |, \R\ a literal ~, and \E\ a literal backslash.

Our NTE segment reads Pt prefers Spanish \T\ English interpreter. Order of operations matters here. Unescape the raw message first and that \T\ becomes &, which is the subcomponent separator, so your next split cuts the note in half.

Split all the way down first. Unescape the leaf values last:

ESCAPES = {"F": FIELD, "S": COMP, "R": REP, "E": ESC, "T": SUB}

def unescape(value):
out, i = [], 0
while i < len(value):
if value[i] == ESC:
end = value.find(ESC, i + 1)
if end != -1 and value[i + 1:end] in ESCAPES:
out.append(ESCAPES[value[i + 1:end]])
i = end + 1
continue
out.append(value[i])
i += 1
return "".join(out)

unescape(nte[3]) # -> 'Pt prefers Spanish & English interpreter'

There are also formatting escapes like .br\ in formatted-text fields.
This handles the five delimiter escapes, which is what trips most hand-rolled parsers.

  1. Empty and "" mean different things

This one causes silent data loss.

In HL7 v2, an empty field means "not sent": leave whatever you already have alone. A field containing two double quotes, "", means "this value is now null, delete it."

It matters most on update messages like ADT^A08. Treat the two as the same and you'll either never clear stale data, or wipe good data every time a sender leaves a field blank.

def field_state(value):
if value == '""':
return "delete existing value"
if value == "":
return "not sent, leave existing value alone"
return f"set to {value!r}"

It's very easy to collapse both into None without noticing. Worth checking yours.

  1. Timestamps have variable precision

HL7 timestamps look like YYYY[MM[DD[HH[MM[SS[.S…]]]]]][+/-ZZZZ]. Our MSH-7 is 20260915083012-0400, which is complete. But 20260915 on its own is valid too, and so is 202609151430 with no time zone at all.

Don't feed these to a datetime parser with one fixed format string. Parse by length, keep the precision you were given, and decide explicitly what a missing time zone means for this sender. "Assume UTC" is a risky default when plenty of hospital systems send local time.

  1. You owe them an ACK

Receiving the message isn't the end of your job. The sender expects an acknowledgement: an ACK message whose MSA segment carries a response code and the control ID from the original MSH-10.

AA means accepted. AE means an application error. AR means rejected. Many interface engines hold the queue or resend until they get one, so a receiver that silently swallows messages can back up an entire feed.

import uuid
from datetime import datetime

def build_ack(msh_f, code="AA"):
ack_msh = FIELD.join([
"MSH", msh_f[2],
msh_f[5], msh_f[6], # we are now the sender
msh_f[3], msh_f[4], # they are now the receiver
datetime.now().strftime("%Y%m%d%H%M%S"), "",
"ACK^A01^ACK", uuid.uuid4().hex[:20], "P", msh_f[12],
])
msa = FIELD.join(["MSA", code, msh_f[10]])
return ack_msh + "\r" + msa + "\r"

On the wire, HL7 over TCP usually travels in MLLP framing: a 0x0B byte before the message and 0x1C 0x0D after it. Forget the trailer and the other side waits forever for a message that never finishes arriving.

framed = b"\x0b" + ack.encode() + b"\x1c\x0d"

Should you write your own parser at all?

For learning, yes. It's the fastest way to understand what a feed is actually doing.

For production, probably not from scratch. The hl7 package on PyPI (python-hl7) handles parsing and MLLP, and hl7apy adds validation against the spec's message structures. With several feeds or several sites, an interface engine pays for itself in monitoring and message replay alone.

None of them remove the hard part, though. A library parses the message correctly. It can't tell you which assigning authority your hospital cares about, what's hiding in their Z-segments (custom segments each site defines for itself), or whether "" turns up in their A08s. That's site knowledge, and you only get it by reading real sample messages before you write any mapping code.

If you're scoping HL7 work for a product rather than learning the format, we wrote up what HL7 integration projects actually involve, including why every hospital's "standard" feed ends up slightly different.

IntelliSource Technologies builds healthcare software and EHR integrations for US health systems and healthtech teams. More at intellisourcetech.net.

Top comments (0)