HL7 ADT Message Flow Patterns for Clinical ETL Pipelines
Admit, Discharge, and Transfer (ADT) messages are the event stream that drives patient lifecycle state across an enterprise health system: every admission, bed move, demographic correction, and discharge arrives as a discrete HL7 v2 trigger event that your pipeline must apply in the right order, exactly once, without losing clinical context. Within the FHIR & HL7 v2 Standards Architecture for Clinical ETL, ADT sits at the ingestion boundary — it establishes the demographic and encounter baseline that downstream lab (ORU), order (ORM), and billing flows reference. Get the flow patterns wrong and the damage compounds: duplicate Patient records from non-idempotent retries, orphaned Encounter resources from out-of-order delivery, and audit gaps that fail a HIPAA review. This page is written for the engineer wiring a real ADT feed into a streaming broker and a FHIR-shaped warehouse, and it assumes you will lift the patterns below into a Python service and test them in isolation.
ADT is deceptively simple on the wire — pipe-delimited text over a TCP socket — but the operational contract is strict. The same A08 update can carry a full demographic refresh or a single field change; the same A03 discharge can arrive before the A01 admit it closes; and the same vendor can send a structurally valid message whose semantics violate the spec. The normalization that follows this stage is documented across the Clinical Data Parsing & Transformation Workflows reference, but none of it is safe until ADT ordering and idempotency are deterministic.
Prerequisites & Context
Confirm each item before wiring an ADT flow into your pipeline. They are load-bearing for the implementation that follows.
ADT Trigger Events & Segment Anatomy
An ADT message is identified by MSH-9, whose two components are the message code (ADT) and the trigger event (A01, A03, A08, …). The trigger event is the semantic verb: it tells you what changed and which encounter state transition to apply. A production pipeline only needs to handle a focused subset with high fidelity; the rest can be logged and passed through.
| Trigger | Event | Encounter effect | ETL handling note |
|---|---|---|---|
A01 |
Admit / visit notification | Create in-progress encounter |
Establishes the encounter baseline; key on patient + facility |
A02 |
Transfer a patient | Update assigned location (PV1-3) |
Same encounter; do not create a new one |
A03 |
Discharge / end visit | Close encounter (finished) |
May arrive before A01 — buffer and reconcile |
A04 |
Register an outpatient | Create encounter (ambulatory class) | Treat like A01 with PV1-2 = O |
A08 |
Update patient information | Patch demographics / encounter | Full snapshot, not a delta — diff against current state |
A11 |
Cancel admit | Void the A01 |
Compensating event; reverse the prior state |
A13 |
Cancel discharge | Re-open a finished encounter |
Compensating event; restore prior status |
A40 |
Merge patient identifiers | Re-point all references to surviving MRN | The hardest case — touches the identity layer |
The body of an ADT message is a sequence of segments, each a line of ^~\&-delimited fields. The segments you actually map are a small, stable set. EVN carries the event metadata, PID the patient identity, PV1 the visit, and the optional NK1/GT1 the relationships and guarantor.
| Segment | Name | Cardinality | Drives | Key fields |
|---|---|---|---|---|
MSH |
Message header | 1…1 | Routing, ACK correlation | MSH-7, MSH-9, MSH-10, MSH-12 |
EVN |
Event type | 1…1 | Event timestamp, reason | EVN-2 (recorded), EVN-6 (occurred) |
PID |
Patient identification | 1…1 | Patient resource |
PID-3 (identifier list), PID-5 (name), PID-7 (DOB) |
PV1 |
Patient visit | 1…1 | Encounter resource |
PV1-2 (class), PV1-3 (location), PV1-44 (admit), PV1-45 (discharge) |
NK1 |
Next of kin | 0…* | RelatedPerson |
NK1-2 (name), NK1-3 (relationship) |
GT1 |
Guarantor | 0…* | Account.guarantor |
GT1-3 (name), GT1-11 (type) |
Z** |
Vendor custom | 0…* | FHIR extensions | Vendor-defined |
Encounter status state machine
The trigger events drive a finite state machine for Encounter.status. Modeling it explicitly — rather than blindly overwriting status on every message — is what prevents a late A03 from “closing” an encounter that a newer A13 already re-opened.
Implementation
The pipeline decomposes into four ordered steps: frame handling and partitioning, deterministic parsing, idempotent state application, and FHIR mapping. Each step has a validation gate; nothing advances without passing it.
Step 1: Partition by patient context, not by control ID
Partitioning strategy fixes your ordering guarantees. ADT events must be keyed by a composite of patient MRN plus facility OID so that every event for one patient lands on one partition and is processed sequentially. MSH-10 (Message Control ID) is the correct key for deduplication, but using it as a partition key scatters one patient’s events across partitions and destroys ordering — the single most common cause of orphaned encounters in production.
def partition_key(pid_identifier_list: str, facility_oid: str) -> str:
"""Composite partition key: MRN within its assigning authority + facility.
PID-3 is a repeating field of components: id^check^scheme^assigning_authority.
We extract the MR-typed identifier so that an account number or SSN in the
same list never changes the partition.
"""
mrn = ""
for rep in pid_identifier_list.split("~"):
comps = rep.split("^")
# comps[0]=id, comps[4]=identifier type code (e.g. "MR")
if len(comps) > 4 and comps[4] == "MR":
mrn = comps[0]
break
if not mrn: # fall back to first identifier rather than dropping the event
mrn = pid_identifier_list.split("~")[0].split("^")[0]
return f"{facility_oid}:{mrn}"
Validation gate: assert that two events with the same MRN and facility but different MSH-10 resolve to the same partition key, and that an account-number-only difference in PID-3 does not change it.
Step 2: Parse deterministically against the segment grammar
A resilient parser never relies on fixed positional indexing across the whole message or naive split over the entire payload. It validates the MSH header, resolves the message type and trigger from MSH-9, and stores each segment as a list of fields keyed by segment id (segments like PV1 can repeat). Note that MSH is special: its field separator is itself MSH-1, so the encoding characters land at index 0 after splitting the body.
import re
from dataclasses import dataclass, field
from typing import Dict, List
# Segment id: 2-4 uppercase/alphanumeric chars (covers PID, PV1 and Z-segments).
SEGMENT_RE = re.compile(r"^([A-Z][A-Z0-9]{1,3})\|(.*)$")
@dataclass
class ADTMessage:
control_id: str
message_code: str # MSH-9.1, e.g. "ADT"
trigger_event: str # MSH-9.2, e.g. "A01"
processing_id: str # MSH-11: "P" prod, "T" test, "D" debug
version_id: str # MSH-12, e.g. "2.5.1"
segments: Dict[str, List[List[str]]] = field(default_factory=dict)
def parse_adt(raw: str) -> ADTMessage:
"""Parse an MLLP-stripped HL7 v2 ADT payload into a structured message.
`raw` must already have the MLLP framing bytes (0x0B ... 0x1C 0x0D)
removed. We split defensively on CR/LF because interface engines
disagree on the segment terminator.
"""
segments: Dict[str, List[List[str]]] = {}
for line in re.split(r"[\r\n]+", raw.strip()):
m = SEGMENT_RE.match(line)
if not m:
continue # blank line or non-segment noise
seg_id, body = m.group(1), m.group(2)
segments.setdefault(seg_id, []).append(body.split("|"))
if "MSH" not in segments:
raise ValueError("missing MSH header: not a valid HL7 v2 payload")
msh = segments["MSH"][0]
# After splitting the MSH body on "|":
# [0]=encoding chars (MSH-2) [7]=message type (MSH-9, "ADT^A01")
# [8]=control id (MSH-10) [9]=processing id (MSH-11) [10]=version (MSH-12)
def field_at(i: int) -> str:
return msh[i] if len(msh) > i else ""
msg_type = field_at(7).split("^")
message_code = msg_type[0] if msg_type else ""
trigger_event = msg_type[1] if len(msg_type) > 1 else ""
control_id = field_at(8)
if not (message_code and trigger_event and control_id):
raise ValueError("malformed MSH-9/MSH-10: cannot route ADT event")
return ADTMessage(
control_id=control_id,
message_code=message_code,
trigger_event=trigger_event,
processing_id=field_at(9),
version_id=field_at(10),
segments=segments,
)
Parsers must also resolve escape sequences (\F\ → |, \S\ → ^, \R\ → ~, \E\ → \, \T\ → &) before any field value reaches the mapper, and enforce strict UTF-8 decoding so a Windows-1252 character in a patient name never corrupts downstream PHI. Validation gate: round-trip a known fixture and assert parse_adt(fixture).trigger_event == "A01" and that the resolved PID-5 contains no residual escape tokens.
Step 3: Enforce idempotency, then apply state
Duplicate ADT messages are endemic — network timeouts, vendor retransmission policies, and manual resends all produce them. Idempotency is enforced at the ingestion boundary with a deterministic key, checked against a distributed cache before any state is applied. The TTL should match the sender’s retransmission window (commonly 24–72 hours).
import hashlib
def idempotency_key(msg: ADTMessage, sending_facility_oid: str) -> str:
"""Stable per-message key for dedup. MSH-10 alone is not safe: some
senders reuse control IDs across systems, so we bind the source OID
and the trigger to the control id and hash for a fixed-width key."""
raw = f"{sending_facility_oid}|{msg.control_id}|{msg.trigger_event}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def apply_event(cache, store, msg: ADTMessage, sending_facility_oid: str) -> str:
key = idempotency_key(msg, sending_facility_oid)
# SET NX returns False if the key already exists → duplicate.
if not cache.set(key, "1", nx=True, ex=72 * 3600):
return "DUPLICATE_ACK" # ack the sender, quarantine for audit, do not reprocess
# State transition is keyed on the encounter, guarded by the status FSM.
return store.apply_transition(msg)
Validation gate: feed the same payload twice and assert the second call returns "DUPLICATE_ACK" and the encounter version did not increment.
Step 4: Map to FHIR Patient and Encounter
PID maps to Patient (with PID-3 normalized into Patient.identifier using system-scoped URIs), and PV1 drives Encounter, where PV1-2 (Patient Class) maps to Encounter.class, PV1-3 (Assigned Location) to Encounter.location, and the trigger event sets Encounter.status. Local code systems in fields like PV1-2 must be translated to FHIR ValueSets (for example http://terminology.hl7.org/CodeSystem/v2-0004 for patient class) — resolve them through a FHIR terminology server rather than hardcoding a partial map. The official FHIR R4 Encounter specification is the canonical structure for the status transitions in the state machine above.
TRIGGER_TO_STATUS = {
"A01": "in-progress", "A04": "in-progress", "A08": None, # None = keep current
"A02": "in-progress", "A03": "finished",
"A11": "cancelled", "A13": "in-progress",
}
def to_encounter(msg: ADTMessage, patient_ref: str) -> dict:
pv1 = msg.segments.get("PV1", [[]])[0]
def f(i: int) -> str:
return pv1[i] if len(pv1) > i else ""
status = TRIGGER_TO_STATUS.get(msg.trigger_event)
return {
"resourceType": "Encounter",
"status": status, # caller preserves prior status when None
"class": {"code": f(2)}, # PV1-2, pending terminology translation
"subject": {"reference": patient_ref},
"location": [{"location": {"display": f(3)}}], # PV1-3
}
Edge Cases & Vendor Deviations
ADT feeds break textbook implementations in predictable ways. The table below is the field guide; the notes column is where the on-call time goes.
| Scenario | Vendor / cause | Symptom | Mitigation |
|---|---|---|---|
A03 before A01 |
Async transport, retried admit | Encounter closed with no open state | Sliding reconciliation buffer (below); DLQ after timeout |
A08 as full snapshot |
Epic, Cerner | Treating it as a delta drops fields | Apply as a full replace, diffed against current state |
| Z-segment payloads | Epic (ZPV), Cerner (ZPD) |
Operational data silently lost | Map to FHIR Extension with a stable URI |
Reused MSH-10 |
Athena, legacy gateways | False-positive dedup across sources | Bind sending-facility OID into the idempotency key |
| Missing timezone | Most v2 feeds | EVN-2/PV1-44 off by hours |
Normalize to UTC at ingest; keep original in meta.source |
| Truncated MLLP frame | Payloads >32KB | Parser sees a partial message | Reassemble frames before parsing; timeout to DLQ |
A40 patient merge |
All EHRs | Dangling references to retired MRN | Re-point references atomically; never delete the survivor |
The out-of-order case deserves an explicit buffer. Hold events whose prerequisite state is missing for a bounded window before forcing a decision:
from datetime import datetime, timedelta, timezone
class ReconciliationBuffer:
"""Holds events whose target encounter is not yet open. A discharge that
arrives before its admit waits here until the admit lands or the window
expires, at which point it is routed to the DLQ for manual review."""
def __init__(self, window=timedelta(minutes=15)):
self.window = window
self.pending: dict[str, list] = {}
def hold(self, encounter_key: str, msg) -> None:
self.pending.setdefault(encounter_key, []).append(
(datetime.now(timezone.utc), msg)
)
def drain_expired(self):
now = datetime.now(timezone.utc)
for key, items in list(self.pending.items()):
ripe = [(t, m) for (t, m) in items if now - t >= self.window]
for _, m in ripe:
yield key, m # caller routes to DLQ
remaining = [(t, m) for (t, m) in items if now - t < self.window]
if remaining:
self.pending[key] = remaining
else:
del self.pending[key]
Compliance Note: audit trails for every ADT transition
ADT events change the demographic and encounter record of identifiable patients, so HIPAA’s audit-control requirement (45 CFR §164.312(b)) and 21 CFR Part 11 record-integrity rules apply to every transition you apply and every one you reject. Each ingested event must produce an immutable audit record containing the SHA-256 hash of the raw payload, the parsed segment fingerprint, the transformation rule version, the resulting FHIR resource UUIDs, and the processing latency and retry count.
PHI must never appear in plaintext in application logs or in the DLQ. Apply field-level masking before anything leaves the pipeline (redact PID-5, PID-7, PID-11), and route raw payloads only to encrypted, access-controlled storage (S3 with KMS, GCS with CMEK). The same rejection discipline that gates mandatory fields in lab feeds — documented in handling missing mandatory fields in HL7 ORU messages — applies to ADT: a missing PID-3 is a fatal DLQ event, a deprecated PV1-2 code is a quarantined warning, and an unrecognized Z-segment is an info-level pass-through preserved as an extension. Patient merge (A40) events carry the highest audit weight: record both the retired and surviving identifiers and the full re-pointing operation so the identity change is reconstructable years later.
Troubleshooting
Encounters are being created twice for the same admission.
This is almost always a partition-key problem, not a dedup problem. If you keyed your broker on MSH-10, the original admit and its retransmission land on different partitions and are processed concurrently, so neither sees the other’s idempotency write. Key on facility_oid:MRN (Step 1) so all of a patient’s events serialize on one partition, and bind the sending-facility OID into the idempotency hash (Step 3) so a reused control ID across two systems cannot collide.
A discharge (`A03`) closed an encounter that never had a matching admit.
The discharge arrived before its A01 — normal under async transport. Do not apply a finished status to an encounter you have never seen open; route the event into the reconciliation buffer keyed on the encounter, and let it wait for the admit. If the admit never arrives within the window, drain the event to the DLQ for manual review rather than silently fabricating an encounter.
An `A08` update wiped out fields that were populated by the original admit.
A08 is a full snapshot, not a partial delta — but some downstream writers treat any absent field as “set to null.” Diff the incoming A08 against the current stored state and only overwrite fields the message actually carries a value for. Empty components in a repeating field (for example a missing middle name in PID-5) should be distinguished from a true HL7 null ("" between delimiters versus an explicit \"\" null flavor).
Timestamps are hours off after they land in the warehouse.
HL7 v2 datetime fields like EVN-2 and PV1-44 frequently omit the timezone offset, so a naive parser assumes the server’s local zone. Normalize every timestamp to UTC at ingestion using the sending facility’s known zone, and keep the original string in meta.source. The broader coercion rules for clinical timestamps and other typed fields live under Clinical Data Parsing & Transformation Workflows.
The sender keeps resending the same message and our queue is backing up.
The sender has not received a valid MSA acknowledgment, so it follows its retransmission policy. ADT is a request/response protocol at the transport layer: you must return a structured ACK (or NACK with ERR) for every message, correlated on the control ID. Implement the deterministic acknowledgment path described in the ACK/NACK handling patterns before tuning anything else — the backlog is a symptom of missing or malformed acks, not throughput.