Nirmitee.io
Healthcare

ADT Event Processing: Building a Real-Time Patient Census from HL7 ADT Messages

March 16, 202610 min readUpdated Oct 19, 2026
Written by
Jitendra Choudhary
Jitendra Choudhary

CTO & Co-Founder

CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

ADT Event Processing: Building a Real-Time Patient Census from HL7 ADT Messages

ADT events arrive through an HL7 interface. For how that interface is framed, acknowledged and built, see what an HL7 interface is. If you want these feeds built and run for you, see our HL7 integration services.

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.

Related case study: ADT streams power our real-time bed management platform with ER wait times and.

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. The demographic side of an A08 lands on the Patient resource, and our FHIR Patient resource guide covers how identifiers, names and merge links are modeled there.

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. Our FHIR Encounter resource guide maps each PV1 field to its Encounter element and walks through every status value. 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 product engineering services. 

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

What are ADT messages in HL7 and why do they matter for hospitals?

ADT (Admit, Discharge, Transfer) messages are HL7v2 messages that track where every patient is, was, and will be — the heartbeat of hospital operations. Every hospital generates thousands daily, yet most health systems still rely on manual census counts and whiteboard bed boards. According to a Becker's Hospital Review analysis, hospitals lose up to $2 million annually from inefficient patient flow, which a real-time ADT-driven census can directly address.

Which ADT event types matter most for building a patient census?

Five events account for approximately 90% of ADT traffic: A01 (admit — bed assigned, census increments), A02 (transfer — one unit decrements, another increments), A03 (discharge — census decrements, bed pending cleaning), A04 (registration for outpatient and ED arrivals before a bed is assigned), and A08 (updates). HL7v2 defines over 50 ADT event types (A01 through A62), but these five drive census management.

How does a real-time patient census improve hospital throughput?

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%. It works by processing each ADT event through a finite state machine where every patient visit maintains its current state and location, replacing manual census counts and the phone calls nursing staff make to find available beds.

What is the most common bug when processing HL7 A02 transfer messages?

The most common bug is reading only PV1-3 (the new location) and ignoring PV1-6 (the prior location). A transfer must simultaneously decrement one unit's census and increment another's — if you only read PV1-3, you increment the new unit but never decrement the old one, causing census drift over time. A related trap: some EHRs like Epic sometimes send A08 instead of A02 for certain transfers, so A08 handlers must treat location changes as implicit transfers.

Why does simply counting admits and discharges fail for census tracking?

Counting A01 admits and subtracting A03 discharges fails in production because it ignores transfers, re-admissions, and out-of-order messages. The correct approach is a patient location state machine with states like pre-admit, registered, admitted, and discharged, where ADT events drive transitions. Idempotency guards using sequence numbers reject duplicate or out-of-order events, and the same event stream can be mapped to FHIR Encounter resources for downstream systems.

How does Nirmitee.io approach healthcare software development?

Nirmitee.io follows a healthcare-first engineering approach — every solution is built with HIPAA compliance, HL7/FHIR interoperability standards, and clinical workflow requirements at the core. We combine domain expertise with modern engineering practices to deliver production-ready systems.
Share