Skip to content
>GLB_
Go back

HL7 v2 ADT feeds: from pipe-delimited events to an encounters table

// healthcare · hl7 v2

TL;DR

An ADT feed is a stream of events about a visit, not a table of visits. Parsing a message takes about 40 lines of Python. Most of the work is elsewhere: ACK only after the raw message is stored, key visits by facility + visit number, and rebuild each visit from all of its events so that cancellations and out-of-order delivery come out right.

The problem

The Roster API series answers who is ours. An ADT feed answers what just happened to them: admitted last night, transferred to ICU, discharged to a skilled nursing facility. Care management teams in value-based care live on this: a discharge without a follow-up visit within 7 days is a readmission waiting to happen.

Since May 2021, hospitals that take Medicare must send these notifications electronically (the Conditions of Participation change in the CMS Interoperability and Patient Access rule, CMS-9115-F). In practice they arrive as HL7 v2 ADT messages, straight from the hospital or through an HIE.

This is what one looks like (synthetic, segments on separate lines for readability; on the wire they are separated by \r):

MSH|^~\&|EPIC|MERCY_HOSP|GLB|VBC_ORG|20261030221504||ADT^A01^ADT_A01|MSG00001|P|2.5.1
EVN|A01|20261030221500
PID|1||884213^^^MERCY_HOSP^MR~1EG4TE5MK73^^^CMS^MC||Rivera^Ana^L||19520314|F|||12 Oak St^^Springfield^MA^01103
PV1|1|I|4W^412^B^MERCY_HOSP||||1234567893^Chen^David^^^^^^NPI|||MED||||7|||||V556677|||||||||||||||||||||||||202610302158

The four segments you need first:

SegmentWhat it holdsFields worth knowing
MSHEnvelopeMSH-4 sending facility, MSH-9 message type (ADT^A01), MSH-10 control ID, MSH-12 version
EVNEventEVN-2 when the event was recorded
PIDPatientPID-3 identifiers (repeating), PID-5 name, PID-7 birth date
PV1VisitPV1-2 patient class, PV1-3 location, PV1-19 visit number, PV1-36 discharge disposition, PV1-44/PV1-45 admit/discharge time

And the trigger events that matter for an encounters table:

TriggerMeaning
A01Admit (inpatient)
A04Register (ED, outpatient)
A02Transfer
A03Discharge
A08Update patient/visit information
A11Cancel admit
A13Cancel discharge
A06 / A07Outpatient → inpatient / inpatient → outpatient
A40Merge two patient identities

What I tried

line.split("|") and indexing

It almost works, which is why it’s dangerous:

One row per message

I inserted every A01 as an encounter and updated it on A03. Then:

Messages are events. Treating them as rows loses the order and the cancellations.

What actually worked

1. A small parser that respects the structure

# hl7v2.py
import re
from datetime import datetime

ESCAPES = {"F": "|", "S": "^", "T": "&", "R": "~", "E": "\\"}
TS_FORMATS = {4: "%Y", 6: "%Y%m", 8: "%Y%m%d", 10: "%Y%m%d%H", 12: "%Y%m%d%H%M", 14: "%Y%m%d%H%M%S"}


def unescape(value: str) -> str:
    return re.sub(r"\\([FSTRE])\\", lambda m: ESCAPES[m.group(1)], value)


def parse(raw: str) -> dict[str, list[list[str]]]:
    segments: dict[str, list[list[str]]] = {}
    for line in re.split(r"\r\n|\r|\n", raw.strip()):
        if not line:
            continue
        fields = line.split("|")
        if fields[0] == "MSH":
            fields.insert(1, "|")  # MSH-1 is the field separator itself
        segments.setdefault(fields[0], []).append(fields)
    return segments


def get_raw(msg, seg: str, field: int) -> str:
    try:
        return msg[seg][0][field]
    except (KeyError, IndexError):
        return ""


def get(msg, seg: str, field: int, comp: int = 1, rep: int = 1) -> str:
    try:
        value = msg[seg][0][field]
        value = value.split("~")[rep - 1]
        value = value.split("^")[comp - 1]
    except (KeyError, IndexError):
        return ""
    return unescape(value)


def identifiers(msg) -> dict[str, str]:
    """PID-3 repetitions keyed by identifier type code (CX.5)."""
    out = {}
    for rep in get_raw(msg, "PID", 3).split("~"):
        comps = rep.split("^")
        if comps[0]:
            out[comps[4] if len(comps) > 4 else ""] = unescape(comps[0])
    return out


def parse_ts(value: str) -> datetime | None:
    m = re.fullmatch(r"(\d{4,14})(?:\.\d+)?([+-]\d{4})?", value or "")
    if not m:
        return None
    digits, tz = m.groups()
    fmt = TS_FORMATS[len(digits)]
    if tz:
        return datetime.strptime(digits + tz, fmt + "%z")
    return datetime.strptime(digits, fmt)

On the sample message:

>>> msg = parse(raw)
>>> get(msg, "MSH", 9), get(msg, "MSH", 9, comp=2), get(msg, "PV1", 19)
('ADT', 'A01', 'V556677')
>>> identifiers(msg)
{'MR': '884213', 'MC': '1EG4TE5MK73'}
>>> parse_ts(get(msg, "PV1", 44))
datetime.datetime(2026, 10, 30, 21, 58)

What this deliberately ignores: custom delimiters (it assumes the standard ^~\& from MSH-2), sub-components (&), and the rarer escapes like \X0D\. Read MSH-2 and MSH-18 (character set) before you assume. Many feeds are Latin-1, not UTF-8. For production-grade parsing of every edge, use a library. For a feed you control end to end, this is enough to see what is inside.

2. Receive over MLLP, ACK after storing

ADT usually travels over MLLP: a TCP connection where each message is framed by 0x0B at the start and 0x1C 0x0D at the end. The sender waits for an ACK before sending the next message. Port 2575 is the registered default.

# mllp_server.py
import asyncio
from datetime import datetime

from hl7v2 import get, get_raw, parse

SB, EB, CR = b"\x0b", b"\x1c", b"\r"


def ack(msg, code: str) -> str:
    msh = msg["MSH"][0]
    now = datetime.now().strftime("%Y%m%d%H%M%S")
    trigger = get(msg, "MSH", 9, comp=2)
    return "\r".join([
        f"MSH|^~\\&|{msh[5]}|{msh[6]}|{msh[3]}|{msh[4]}|{now}||ACK^{trigger}^ACK"
        f"|ACK{msh[10]}|P|{get_raw(msg, 'MSH', 12)}",
        f"MSA|{code}|{msh[10]}",
    ]) + "\r"


def land(raw: str, msg) -> None:
    # Append-only write keyed by (sending facility, control id). Replace with your store.
    with open("landing.hl7", "a", encoding="utf-8") as f:
        f.write(raw.replace("\r", "\n") + "\n")


async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
    while True:
        try:
            await reader.readuntil(SB)
            frame = await reader.readuntil(EB + CR)
        except asyncio.IncompleteReadError:
            break
        raw = frame[:-2].decode("latin-1")
        msg = parse(raw)
        try:
            land(raw, msg)
            reply = ack(msg, "AA")
        except Exception:
            reply = ack(msg, "AE")
        writer.write(SB + reply.encode("latin-1") + EB + CR)
        await writer.drain()
    writer.close()


async def main() -> None:
    server = await asyncio.start_server(handle, "0.0.0.0", 2575)
    async with server:
        await server.serve_forever()


if __name__ == "__main__":
    asyncio.run(main())

The reply for the sample:

MSH|^~\&|GLB|VBC_ORG|EPIC|MERCY_HOSP|20261031172826||ACK^A01^ACK|ACKMSG00001|P|2.5.1
MSA|AA|MSG00001

Two rules are worth more than the code:

3. Rebuild each visit from its events

A visit is identified by PV1-19, and only within the sending facility, so the key is (MSH-4, PV1-19). Some feeds leave PV1-19 empty. For those, fall back to MRN + admit time, and count how often you need the fallback.

Turn every landed message into an event:

# encounters.py
from hl7v2 import get, identifiers, parse, parse_ts


def to_event(raw: str) -> dict:
    msg = parse(raw)
    ids = identifiers(msg)
    return {
        "facility": get(msg, "MSH", 4),
        "visit": get(msg, "PV1", 19),
        "trigger": get(msg, "MSH", 9, comp=2),
        "event_ts": parse_ts(get(msg, "EVN", 2)) or parse_ts(get(msg, "MSH", 7)),
        "control_id": get(msg, "MSH", 10),
        "mrn": ids.get("MR"),
        "mbi": ids.get("MC"),
        "patient_class": get(msg, "PV1", 2),
        "location": get(msg, "PV1", 3),
        "admit_ts": parse_ts(get(msg, "PV1", 44)),
        "discharge_ts": parse_ts(get(msg, "PV1", 45)),
        "disposition": get(msg, "PV1", 36),
    }

Then fold all events of one visit, sorted by when they happened, not by when they arrived:

def fold(events: list[dict]) -> dict:
    """Rebuild one visit's state from all of its events, in event-time order."""
    enc = {"status": None, "admit_ts": None, "discharge_ts": None}
    for e in sorted(events, key=lambda e: (e["event_ts"], e["control_id"])):
        match e["trigger"]:
            case "A01" | "A04":
                enc.update(status="active", admit_ts=e["admit_ts"], discharge_ts=None)
            case "A03":
                enc.update(status="discharged", discharge_ts=e["discharge_ts"],
                           disposition=e["disposition"])
            case "A11":
                enc.update(status="cancelled", admit_ts=None)
            case "A13":
                enc.update(status="active", discharge_ts=None, disposition=None)
        for key in ("mrn", "mbi", "patient_class", "location"):
            if e[key]:
                enc[key] = e[key]
    return enc

Feed it the messy case from above, out of order: admit, discharge, cancel discharge, real discharge.

>>> fold([a03_second, a13, a01, a03_first])
{'status': 'discharged', 'admit_ts': datetime(2026, 10, 30, 21, 58),
 'discharge_ts': datetime(2026, 11, 3, 8, 45), 'mrn': '884213',
 'mbi': '1EG4TE5MK73', 'patient_class': 'I', 'location': '4W', 'disposition': '06'}
>>> fold([a01, a11])
{'status': 'cancelled', 'admit_ts': None, 'discharge_ts': None, ...}

Every new message for a visit triggers a re-fold of that visit, never a patch of the current row. Arrival order stops mattering. A late A11 turns the visit into cancelled the next time it is folded. It is the same idea as the versioned Group in part 3 of the roster series: keep the log, derive the state.

Things the fold leaves for the next iteration:

If the next stop is FHIR, the HL7 v2-to-FHIR IG maps PV1 to Encounter (PV1-2 → Encounter.class, PV1-44/45 → Encounter.period). The folded visit is what you map. Mapping each message is the wrong unit.

If the folded visits end up in Tuva, they land next to the claims-derived encounters without being matched to them. One hospital stay can appear twice (Tuva’s encounter grouper only groups claims).

Key takeaways

References


01 Nov 2026 · #healthcare #python #dataengineering

GLB_


Share this post:

Related Posts


Previous Post
Synthea vs SynPUF: picking synthetic healthcare data for pipeline tests
Next Post
Migrating from SFTP to a roster API: dual-run and reconciliation