Every hospital generates thousands of ADT (Admit, Discharge, Transfer) messages daily. These HL7v2 messages are the heartbeat of hospital operations — they track where every patient is, was, and will be. Yet most health systems still rely on manual census counts, whiteboard bed boards, and nursing staff making phone calls to figure out which beds are available. The gap between what ADT data can tell you and what hospitals actually do with it represents one of the largest operational inefficiencies in healthcare.
Building a real-time patient census from ADT messages is not just a technical exercise — it directly impacts patient throughput, ED boarding times, and revenue. According to a Becker's Hospital Review analysis, hospitals lose up to $2 million annually from inefficient patient flow. A real-time census dashboard, driven by a properly implemented ADT state machine, can reduce ED boarding times by 20-30% and improve bed turnover by 15%.
This guide walks through the complete technical implementation: ADT message types and their semantics, building a patient location state machine, handling the inevitable out-of-order messages, mapping ADT events to FHIR Encounters, and integrating with bed management systems. We include working code for the state machine and ADT-to-FHIR mapping that you can adapt to your environment.
Understanding ADT Message Types: The Five Events That Drive Patient Census
HL7v2 defines over 50 ADT event types (A01 through A62), but five events account for approximately 90% of all ADT traffic and are the ones that matter for census management. Understanding their precise semantics — not just their names — is critical for building a correct state machine.
A01 — Admit/Visit Notification
An A01 fires when a patient is assigned to an inpatient bed. This is the most significant census event because it increments the unit census and assigns a specific bed. The PV1 segment contains the assigned patient location (PV1-3) in the format Unit^Room^Bed^Facility. The admit date/time lives in PV1-44. A01 messages typically include PID (patient demographics), PV1 (visit information), NK1 (next of kin), DG1 (diagnosis), and IN1 (insurance) segments. In practice, an A01 means “this bed is now occupied — update the census.”
A02 — Transfer
A02 fires when a patient moves from one location to another within the facility. This is the most complex census event because it simultaneously decrements one unit's census and increments another's. The PV1-3 field contains the new location, while PV1-6 contains the prior location. Missing this distinction is one of the most common implementation bugs — if you only read PV1-3, you will increment the new unit but never decrement the old one, causing census drift over time.
A03 — Discharge/End Visit
A03 signals that a patient has been discharged. This decrements the census for the patient's assigned unit and marks the bed as available (pending cleaning). The discharge date/time is in PV1-45. Note that an A03 does not mean the bed is ready for the next patient — it means the patient has left. Bed turnaround tracking (time from discharge to clean to available) requires integration with Environmental Services (EVS) systems, which we cover in the bed management section.
A04 — Register a Patient
A04 fires for outpatient registrations and ED arrivals. This is sometimes confused with A01, but the distinction matters: A04 means the patient is registered (checked in) but not yet assigned an inpatient bed. For ED census tracking, A04 events are essential — they show patients who are in the department but may not have a bed assignment yet. The patient class field (PV1-2) distinguishes between inpatient (“I”), outpatient (“O”), and emergency (“E”) visits.
A08 — Update Patient Information
A08 is the catch-all update event. It fires for demographic changes, insurance updates, attending physician changes, and other modifications that do not affect the patient's location or census count. However, A08 messages can include location changes in some EHR implementations (Epic, for example, sometimes sends A08 instead of A02 for certain transfer types). Your state machine must examine the PV1-3 field in A08 messages and treat location changes as implicit transfers.
Building the Patient Location State Machine
A naive approach to census management — simply counting A01s and subtracting A03s — fails in production because it does not account for transfers, re-admissions, or out-of-order messages. The correct approach is a finite state machine where each patient visit maintains its current state and location, and ADT events drive transitions between states.
Here is a production-ready state machine implementation in Python. This handles all five ADT event types with proper validation and idempotency guards:
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
class PatientState(Enum):
PRE_ADMIT = "pre_admit"
REGISTERED = "registered"
ADMITTED = "admitted"
DISCHARGED = "discharged"
@dataclass
class PatientVisit:
visit_id: str
patient_id: str
state: PatientState = PatientState.PRE_ADMIT
current_location: Optional[str] = None
prior_location: Optional[str] = None
admit_time: Optional[datetime] = None
discharge_time: Optional[datetime] = None
last_event_time: Optional[datetime] = None
last_sequence_number: int = 0
event_log: list = field(default_factory=list)
class ADTStateMachine:
def __init__(self):
self.visits: dict[str, PatientVisit] = {}
self.census: dict[str, set] = {} # location -> set of visit_ids
def process_event(self, event_type: str, visit_id: str,
patient_id: str, location: str,
prior_location: str = None,
event_time: datetime = None,
sequence_number: int = 0) -> bool:
# Idempotency: reject duplicate or out-of-order events
visit = self.visits.get(visit_id)
if visit and sequence_number <= visit.last_sequence_number:
logger.warning(f"Skipping duplicate/old event {event_type} "
f"seq={sequence_number} for visit {visit_id}")
return False
TRANSITIONS = {
"A04": self._handle_register,
"A01": self._handle_admit,
"A02": self._handle_transfer,
"A03": self._handle_discharge,
"A08": self._handle_update,
}
handler = TRANSITIONS.get(event_type)
if not handler:
logger.warning(f"Unknown ADT event type: {event_type}")
return False
return handler(visit_id, patient_id, location,
prior_location, event_time, sequence_number)
def _handle_register(self, visit_id, patient_id, location,
prior_location, event_time, seq):
visit = self._get_or_create_visit(visit_id, patient_id)
if visit.state not in (PatientState.PRE_ADMIT,):
logger.warning(f"A04 Register on visit {visit_id} "
f"in state {visit.state} -- ignoring")
return False
visit.state = PatientState.REGISTERED
self._assign_location(visit, location)
self._record_event(visit, "A04", event_time, seq)
return True
def _handle_admit(self, visit_id, patient_id, location,
prior_location, event_time, seq):
visit = self._get_or_create_visit(visit_id, patient_id)
if visit.state == PatientState.DISCHARGED:
logger.warning(f"A01 Admit on discharged visit {visit_id}")
return False
visit.state = PatientState.ADMITTED
visit.admit_time = event_time
self._assign_location(visit, location)
self._record_event(visit, "A01", event_time, seq)
return True
def _handle_transfer(self, visit_id, patient_id, location,
prior_location, event_time, seq):
visit = self.visits.get(visit_id)
if not visit or visit.state != PatientState.ADMITTED:
logger.warning(f"A02 Transfer on non-admitted visit {visit_id}")
return False
old_loc = prior_location or visit.current_location
self._remove_from_location(visit, old_loc)
self._assign_location(visit, location)
self._record_event(visit, "A02", event_time, seq)
return True
def _handle_discharge(self, visit_id, patient_id, location,
prior_location, event_time, seq):
visit = self.visits.get(visit_id)
if not visit:
logger.warning(f"A03 Discharge for unknown visit {visit_id}")
return False
self._remove_from_location(visit, visit.current_location)
visit.state = PatientState.DISCHARGED
visit.discharge_time = event_time
visit.current_location = None
self._record_event(visit, "A03", event_time, seq)
return True
def _handle_update(self, visit_id, patient_id, location,
prior_location, event_time, seq):
visit = self.visits.get(visit_id)
if not visit:
return False
# Check for implicit transfer via A08
if location and location != visit.current_location:
logger.info(f"A08 with location change detected -- "
f"treating as implicit transfer")
return self._handle_transfer(visit_id, patient_id,
location, prior_location,
event_time, seq)
self._record_event(visit, "A08", event_time, seq)
return True
def _get_or_create_visit(self, visit_id, patient_id):
if visit_id not in self.visits:
self.visits[visit_id] = PatientVisit(
visit_id=visit_id, patient_id=patient_id)
return self.visits[visit_id]
def _assign_location(self, visit, location):
if visit.current_location:
self._remove_from_location(visit, visit.current_location)
visit.prior_location = visit.current_location
visit.current_location = location
if location:
self.census.setdefault(location, set()).add(visit.visit_id)
def _remove_from_location(self, visit, location):
if location and location in self.census:
self.census[location].discard(visit.visit_id)
if not self.census[location]:
del self.census[location]
def _record_event(self, visit, event_type, event_time, seq):
visit.last_event_time = event_time
visit.last_sequence_number = seq
visit.event_log.append({
"event": event_type, "time": event_time, "seq": seq
})
def get_unit_census(self, unit_prefix: str) -> int:
count = 0
for location, visits in self.census.items():
if location.startswith(unit_prefix):
count += len(visits)
return count
def get_total_census(self) -> int:
return sum(len(v) for v in self.census.values()) Key design decisions in this implementation: (1) Each visit maintains its own state independently, allowing the system to handle multiple concurrent visits for the same patient. (2) The census dictionary maps locations to sets of visit IDs, making census queries O(1) per location. (3) Sequence numbers provide idempotency — replayed or out-of-order messages are rejected. (4) A08 events check for implicit location changes, handling EHR-specific quirks.
Handling Out-of-Order Messages
In production HL7 environments, messages arrive out of order more often than most developers expect. Network retries, multi-threaded interface engines, and failover scenarios all contribute to messages arriving in non-chronological order. A transfer (A02) might arrive before the corresponding admit (A01), or a duplicate A01 might arrive after the patient has already been discharged.
There are three complementary strategies for handling this:
1. Sequence Number Validation
The MSH-13 (Message Control ID) and EVN-2 (Recorded Date/Time) fields provide ordering guarantees. Our state machine rejects events with sequence numbers less than or equal to the last processed sequence for that visit. This prevents replay attacks and duplicate processing. In high-volume environments, maintain a sliding window of processed message control IDs (typically 24 hours) to catch duplicates even after system restarts.
2. Buffered Reordering Window
For environments where out-of-order delivery is common, implement a short buffer window (typically 15-60 seconds). Incoming messages are held in a priority queue sorted by EVN-2 timestamp. After the buffer window expires, messages are released to the state machine in chronological order. This adds latency but guarantees correct ordering for messages that arrive within the window. The trade-off is configurable: shorter windows mean lower latency but more out-of-order rejections; longer windows mean higher latency but more correct ordering. Most implementations settle on 30 seconds as the sweet spot.
3. Retroactive State Correction
When an event arrives that should have been processed earlier (e.g., an A01 arrives after an A02 for the same visit), the system can retroactively replay the event log in correct chronological order. This is the most complex strategy but provides the strongest correctness guarantees. We recommend implementing it as a background reconciliation process rather than inline, to avoid impacting real-time census latency. Run reconciliation every 5 minutes, comparing the state machine's view against the ordered event log.
Mapping ADT Events to FHIR Encounters
Modern healthcare systems increasingly need ADT data represented as FHIR resources for interoperability. The primary FHIR resource for ADT data is the Encounter resource, with supporting Patient, Location, and Condition resources. Here is how ADT events map to FHIR Encounter status transitions:
The following code demonstrates mapping an ADT event to a FHIR Encounter resource update. This handles the critical status transitions and location tracking that census dashboards need:
from datetime import datetime
# ADT Event Type -> FHIR Encounter.status mapping
ADT_TO_ENCOUNTER_STATUS = {
"A04": "arrived", # Registered -> arrived
"A01": "in-progress", # Admitted -> in-progress
"A02": "in-progress", # Transfer (still in-progress, location changes)
"A03": "finished", # Discharged -> finished
"A08": None, # Update -- status unchanged
}
# Patient class -> FHIR Encounter.class mapping
PATIENT_CLASS_TO_ENCOUNTER_CLASS = {
"I": {"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "IMP", "display": "inpatient encounter"},
"O": {"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "AMB", "display": "ambulatory"},
"E": {"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "EMER", "display": "emergency"},
}
def adt_to_fhir_encounter(event_type, visit_id, patient_id,
location, prior_location=None,
event_time=None, patient_class="I"):
"""Convert an ADT event into a FHIR Encounter resource."""
encounter = {
"resourceType": "Encounter",
"id": visit_id,
"status": ADT_TO_ENCOUNTER_STATUS.get(event_type, "unknown"),
"class": PATIENT_CLASS_TO_ENCOUNTER_CLASS.get(
patient_class, PATIENT_CLASS_TO_ENCOUNTER_CLASS["I"]),
"subject": {"reference": f"Patient/{patient_id}"},
"location": [],
"statusHistory": [],
}
# Set period based on event type
if event_type == "A01" and event_time:
encounter["period"] = {"start": event_time.isoformat()}
elif event_type == "A03" and event_time:
encounter["period"] = encounter.get("period", {})
encounter["period"]["end"] = event_time.isoformat()
# Current location
if location:
loc_parts = location.split("^")
loc_display = " > ".join(p for p in loc_parts if p)
encounter["location"].append({
"location": {
"display": loc_display,
"identifier": {"value": location}
},
"status": "active",
"period": {"start": (event_time or datetime.now()).isoformat()}
})
# Prior location (for transfers)
if event_type == "A02" and prior_location:
loc_parts = prior_location.split("^")
loc_display = " > ".join(p for p in loc_parts if p)
encounter["location"].append({
"location": {
"display": loc_display,
"identifier": {"value": prior_location}
},
"status": "completed",
"period": {"end": (event_time or datetime.now()).isoformat()}
})
return encounter This mapping follows the FHIR Encounter state machine specification. The key insight is that ADT A02 (Transfer) does not change the Encounter status — the patient is still “in-progress” — but it updates the location array, marking the old location as “completed” and the new location as “active.” This distinction is critical for systems that consume FHIR Encounters for operational dashboards and reporting.
Building a Real-Time Census Dashboard
With the state machine processing ADT events and producing FHIR Encounters, the final piece is a real-time census dashboard that gives nursing supervisors, bed management staff, and hospital administrators instant visibility into patient flow.
The dashboard should surface five core metrics, each derived directly from the state machine:
- Total Census: Count of all active visits (state = ADMITTED). This is the single most important number for hospital operations. Updated within 100ms of each ADT event.
- Unit-Level Census: Census broken down by nursing unit (ICU, Med/Surg, ED, etc.), derived from the location prefix in PV1-3. Include capacity and percentage utilization with color-coded thresholds (green <80%, amber 80-95%, red >95%).
- Admissions/Discharges: Rolling counts of A01 and A03 events for the current day, shift, and hour. The admission-to-discharge ratio indicates whether the hospital is filling or emptying.
- Pending Transfers: Patients with transfer orders entered but A02 not yet received. This gap between order and execution is a key bottleneck metric — long pending transfer times indicate operational friction.
- Bed Turnaround Time: Time from A03 (discharge) to the next A01 (admit) for each bed. This metric requires joining ADT data with EVS (cleaning) status and is the primary lever for improving throughput.
For the technical implementation, we recommend a WebSocket-based architecture. The state machine publishes census change events to a message broker (Redis Pub/Sub or Kafka), and the dashboard frontend subscribes to real-time updates. This avoids polling and delivers sub-second updates to all connected clients. For alerting integration, configure threshold-based alerts (e.g., ICU census >95%) that push to PagerDuty or the hospital's notification system.
Bed Management Integration
A census dashboard tells you where patients are. A bed management system tells you where patients can go. Integrating the two creates a complete operational picture that enables proactive patient flow management rather than reactive firefighting.
The integration architecture involves four data sources beyond ADT:
- EVS (Environmental Services): Cleaning status for each bed (dirty, in-progress, clean, inspected). Most EVS systems send HL7 messages or provide API endpoints. The bed status transitions from “occupied” (A01) to “dirty” (A03) to “cleaning” (EVS dispatch) to “clean” (EVS complete) to “available” (inspected). Each transition has a timestamp, enabling turnaround time measurement.
- Nurse Call Systems: Real-time patient acuity signals. A patient pressing the call button frequently may indicate deterioration and potential ICU transfer. Integrating nurse call data with the census provides predictive capacity planning.
- Bed Sensors/IoT: Pressure sensors on beds detect physical occupancy independent of ADT status. This catches discrepancies — a bed marked “available” in the ADT system but physically occupied (or vice versa). IoT data serves as a reconciliation layer for census accuracy.
- Surgical Scheduling: Planned admits from the surgical schedule allow predictive census modeling. If 12 surgeries are scheduled for tomorrow, the system can forecast bed demand by unit and time, enabling proactive discharge planning. This data typically flows through integration engines like Mirth Connect as SIU (Schedule Information Unsolicited) messages.
The integration engine (Mirth Connect, Rhapsody, or a custom solution) sits between these data sources and the processing layer. It handles message routing, transformation between different message formats, validation, and guaranteed delivery. For organizations building new ADT processing pipelines, we recommend the FHIR Facade pattern — accept HL7v2 ADT messages inbound but convert to FHIR resources internally, providing a modern API surface for downstream consumers while maintaining backward compatibility with legacy EHR systems.
Production Considerations
Before deploying an ADT-based census system, address these operational concerns:
- Message volume: A 500-bed hospital generates 15,000-50,000 ADT messages per day. Your processing pipeline must handle sustained throughput of 50+ messages/second with spikes during shift changes and batch processes. Use async processing with backpressure controls.
- Census reconciliation: Run a daily midnight census reconciliation that compares the state machine's view against the EHR's official census report. Drift happens — catch it early. Log discrepancies for root cause analysis.
- Failover and recovery: If the census engine goes down, it must be able to rebuild state from the event log on restart. Store all raw ADT messages in an append-only log (Kafka works well here) and replay from the last known checkpoint. See our guide on disaster recovery for integration engines.
- Multi-facility: For health systems with multiple hospitals, the facility code in PV1-3.4 distinguishes locations. Build your census data model with facility as a first-class dimension, not an afterthought.
- Compliance: ADT data contains PHI. All processing, storage, and display must comply with HIPAA. Implement audit logging, role-based access to the dashboard, and encryption at rest and in transit.
At Nirmitee, we build healthcare integration systems that process millions of ADT messages for real-time operational intelligence. If your organization is struggling with census accuracy, bed management visibility, or ADT processing reliability, reach out — we have done this before and can help you get it right the first time.
Need expert help with healthcare data integration? Explore our Healthcare Interoperability Solutions to see how we connect systems seamlessly. We also offer specialized Healthcare Software Product Development services. Talk to our team to get started.



