← All articles

HL7v2 to FHIR Migration: A Practical Engineering Guide

Last updated: September 2026

This is a working reference for engineers and integration leads moving clinical data from HL7v2 to FHIR R4. You'll get the segment-to-resource mapping, a sequenced migration process, and the specific pitfalls that stall these projects.

HL7v2 vs. FHIR: what actually differs

HL7v2 is a pipe-and-hat delimited messaging standard, the workhorse for intra-facility interfaces since the early 1990s. A message is a stream of segments (one per line, 3-letter code), each split into fields by |, components by ^, subcomponents by &, repetitions by ~, escaped with \. Encoding characters are declared in MSH-2.

Three properties of v2 drive everything about the migration:

  • The wire is not HTTP. Classic v2 rides MLLP over a raw TCP socket, framing each message with <VT> … <FS><CR>. FHIR is REST over HTTPS. You're changing the transport, not just the payload.
  • Delivery is acknowledged, not request/response. The receiver returns an ACK; MSA-1 carries AA/AE/AR and MSA-2 echoes the original MSH-10 control ID. There's no native retransmit; you dedup on control ID.
  • Everything about one patient rides in one flat message. Semantics are implicit.

FHIR R4 (current patch 4.0.1), the first release to include normative content, models healthcare as resources: discrete, addressable JSON/XML objects (Patient, Encounter, Observation) exposed over a RESTful API. Where v2 pushes flat messages, FHIR is a resource graph wired together by References (Encounter.subject → Reference(Patient/123)). A few concepts you cannot skip:

  • id vs. identifier. Resource.id is the server-assigned technical key. Your v2 MRN/SSN lands in the business identifier (Patient.identifier with a system URI). Never map an MRN to Resource.id.
  • CodeableConcept. Coded values carry system + code + display. This is the target for OBX-3, diagnoses, and every other coded field.
  • Profiles and US Core. A base resource is permissive; a profile (StructureDefinition) constrains cardinality, terminology bindings, and must-support elements. For US work, conforming to US Core (the USCDI baseline under ONC/CMS rules) is usually the real acceptance criterion, not "valid FHIR."
  • Terminology is first-class. LOINC (labs, vitals), SNOMED CT (problems, procedures), RxNorm (meds), ICD-10-CM (diagnoses), CVX (immunizations), UCUM (units). v2 carries local codes, so a terminology-mapping layer is almost always required.

The mapping

Message → FHIR resource(s)

HL7v2 messageTriggerFHIR R4 resource(s)
ADT (A01/A03/A04/A08…)admit/discharge/register/updatePatient + Encounter (+ Condition from DG1, Coverage from IN1, RelatedPerson from NK1)
ADT A40patient mergePatient with link (replaced-by / replaces)
ORU^R01resultsObservation (per OBX) grouped by DiagnosticReport (per OBR); Specimen from SPM
ORM / OMG^O19 / OML^O21ordersServiceRequest
OMP^O09 / RDEpharmacy orderMedicationRequest
RAS^O17med administrationMedicationAdministration (RXA in a RAS message)
VXU^V04immunization updateImmunization (RXA in a VXU message)
SIU^S12…schedulingAppointment (+ Slot, Schedule)
MDM^T02documentDocumentReference (+ Binary)
DFT^P03chargesChargeItem / Account (+ Invoice); not Claim, which models payer adjudication
BARaccount/billingAccount / Coverage

Segment → resource

SegmentFHIR resource
MSHMessageHeader (only in FHIR "message" Bundles); otherwise metadata/provenance
EVNEVN-1 trigger informs Encounter.status; EVN-2/EVN-6 feed Provenance
PIDPatient
NK1RelatedPerson / Patient.contact
PV1 / PV2Encounter
DG1Condition (encounter diagnosis)
AL1AllergyIntolerance
OBRDiagnosticReport (+ ServiceRequest for the order)
OBXObservation
ORCServiceRequest / order metadata
RXO/RXE/RXAMedicationRequest; RXA → MedicationAdministration (RAS) or Immunization (VXU), context-dependent
IN1/IN2Coverage (+ Organization for payer)
GT1Account.guarantor / RelatedPerson
SCH + AIxAppointment
SPMSpecimen
NTE.note on the parent resource

PID → Patient, field level

PID fieldPatient elementNotes
PID-3 (CX, repeating)identifier[]Each → {system, value}; assigning authority (CX.4) → identifier.system; type code (CX.5: MR/SS/DL) → identifier.type. MRN is primary.
PID-5 (XPN, repeating)name[]family=XPN.1, given=XPN.2; XPN.7 name-type → name.use
PID-7birthDatetruncate DTM to YYYY-MM-DD
PID-8genderM/F/O/U → male/female/other/unknown. Administrative gender only, not birth sex or gender identity
PID-10 / PID-22extensionUS Core Race / Ethnicity (OMB/CDC value sets)
PID-11 (XAD)address[]line/city/state/postalCode/country; XAD.7 → address.use
PID-13/14telecom[]set system (phone/email) + use
PID-16maritalStatus
PID-19 (SSN)identifiersystem = http://hl7.org/fhir/sid/us-ssn, type = SS
PID-29/30deceased[x]deceasedBoolean or deceasedDateTime

Minting stable, unique identifier.system URIs per assigning authority is where most migrations leak. Govern that map centrally, once.

PV1 → Encounter, field level

PV1 fieldEncounter elementNotes
PV1-2classI→IMP, O→AMB, E→EMER (v3 ActEncounterCode)
PV1-3 (PL)location.locationpoint-of-care^room^bed^facility → a Location hierarchy
PV1-7 / PV1-8 / PV1-9participantattending (ATND) / referring (REF) / consulting (CON) → PractitionerRole
PV1-19identifiervisit number, a business identifier, not id
PV1-36hospitalization.dischargeDisposition
PV1-44 / PV1-45period.start / .endadmit/discharge
ADT trigger + PV1-45statusA01→in-progress, A03→finished, A11→cancelled

OBX → Observation, field level

OBX fieldObservation elementNotes
OBX-2drives value[x]NM→valueQuantity, ST/TX→valueString, CE/CWE→valueCodeableConcept, SN→valueQuantity/Range
OBX-3codeLOINC in system=http://loinc.org; keep local code as a second coding
OBX-5value[x]per OBX-2
OBX-6valueQuantity.unit/.codeUCUM in system=http://unitsofmeasure.org
OBX-7referenceRangelow/high/text
OBX-8interpretationH/L/N/A/HH/LL → v3 ObservationInterpretation
OBX-11statusF→final, P→preliminary, C→corrected, X→cancelled
OBX-14 (else OBR-7)effective[x]observation datetime
OBX-4groups componentsmulti-component results (e.g. BP systolic/diastolic) → Observation.component

OBR becomes the DiagnosticReport (.code from OBR-4, .result[] referencing the child Observations).

Worked example: ORU^R01 → DiagnosticReport + Observations

A lab result message with a glucose value and a paired blood-pressure reading:

MSH|^~\&|LAB|HOSP|EHR|HOSP|20260907120000-0400||ORU^R01|MSG00001|P|2.5.1
PID|1||MRN12345^^^HOSP^MR||DOE^JANE
OBR|1|||24356-6^Basic metabolic panel^LN|||20260907120000-0400
OBX|1|NM|2339-0^Glucose^LN||95|mg/dL|70-99|N|||F
OBX|2|NM|8480-6^Systolic BP^LN|1|128|mm[Hg]|||N|||F
OBX|3|NM|8462-4^Diastolic BP^LN|1|82|mm[Hg]|||N|||F

The two BP OBX lines share an OBX-4 sub-ID, so they collapse into one Observation with two components; the glucose becomes its own Observation. Both hang off one DiagnosticReport:

[
  {
    "resourceType": "DiagnosticReport",
    "id": "report-1",
    "status": "final",
    "code": {
      "coding": [
        { "system": "http://loinc.org", "code": "24356-6", "display": "Basic metabolic panel" }
      ]
    },
    "subject": { "reference": "Patient/123" },
    "effectiveDateTime": "2026-09-07T12:00:00-04:00",
    "result": [
      { "reference": "Observation/obs-glucose" },
      { "reference": "Observation/obs-bp" }
    ]
  },
  {
    "resourceType": "Observation",
    "id": "obs-glucose",
    "status": "final",
    "code": {
      "coding": [
        { "system": "http://loinc.org", "code": "2339-0", "display": "Glucose" }
      ]
    },
    "subject": { "reference": "Patient/123" },
    "effectiveDateTime": "2026-09-07T12:00:00-04:00",
    "valueQuantity": {
      "value": 95,
      "unit": "mg/dL",
      "system": "http://unitsofmeasure.org",
      "code": "mg/dL"
    },
    "referenceRange": [
      { "low": { "value": 70 }, "high": { "value": 99 } }
    ],
    "interpretation": [
      {
        "coding": [
          {
            "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
            "code": "N"
          }
        ]
      }
    ]
  },
  {
    "resourceType": "Observation",
    "id": "obs-bp",
    "status": "final",
    "category": [
      {
        "coding": [
          {
            "system": "http://terminology.hl7.org/CodeSystem/observation-category",
            "code": "vital-signs"
          }
        ]
      }
    ],
    "code": {
      "coding": [
        { "system": "http://loinc.org", "code": "85354-9", "display": "Blood pressure panel" }
      ]
    },
    "subject": { "reference": "Patient/123" },
    "effectiveDateTime": "2026-09-07T12:00:00-04:00",
    "component": [
      {
        "code": {
          "coding": [
            { "system": "http://loinc.org", "code": "8480-6", "display": "Systolic blood pressure" }
          ]
        },
        "valueQuantity": {
          "value": 128,
          "unit": "mmHg",
          "system": "http://unitsofmeasure.org",
          "code": "mm[Hg]"
        }
      },
      {
        "code": {
          "coding": [
            { "system": "http://loinc.org", "code": "8462-4", "display": "Diastolic blood pressure" }
          ]
        },
        "valueQuantity": {
          "value": 82,
          "unit": "mmHg",
          "system": "http://unitsofmeasure.org",
          "code": "mm[Hg]"
        }
      }
    ]
  }
]

The migration process

1. Assess: inventory the source, don't trust the spec

Enumerate every HL7v2 interface: message types (ADT/ORM/ORU/SIU/DFT), sending-app quirks, versions (a 2.3 feed and a 2.5.1 feed differ), and volumes/peak rates. Then capture a large representative message corpus and profile it statistically: population rates, distinct-value counts, and length distributions per field. Two hospitals' "2.5.1" feeds are not the same. Map to observed reality, not the abstract standard. Produce a Z-segment and overloaded-field inventory as a first-class deliverable, signed off by the source team. Unknown Z-segments are the top cause of "we lost data" discovered post-cutover.

Decide the shape up front: a FHIR facade (FHIR API in front of the legacy system of record; fast, read-oriented, inherits legacy constraints) or a full migration (FHIR becomes system of record). Almost everyone ships the hybrid: a one-time bulk backfill of history, then a real-time or batch incremental feed. Freshness requirement sets feed cadence: point-of-care apps need real time; an analytics warehouse tolerates nightly.

2. Map: pin versions, build the transform and the terminology map

Pick R4 and a specific US Core version and pin them; everything downstream (validator, Inferno kit, profiles) keys off that choice. Build the transform segment by segment. Start narrow with ADT (PID + PV1), the backbone and highest value, then expand to results, orders, scheduling.

Run the terminology map in parallel; it is the long pole. Choose your conversion mechanism deliberately and keep the options distinct:

  • HL7 V2-to-FHIR Implementation Guide, HL7's cumulative base mapping expressed as ConceptMaps. Treat it as the authoritative starting point; tailor it to your sending system's dialect.
  • LinuxForHealth hl7v2-fhir-converter, an open-source Java library on HAPI, config-driven via YAML templates, ships ADT templates (A01/A03/A04/A08/A28/A31/A40).
  • $convert-data operation (Azure Health Data Services / the OSS FHIR Server), Liquid templating from the Microsoft FHIR Converter project.
  • FHIR Mapping Language / StructureMap for rigorous, versioned, reusable transforms.

Two clarifications you will be fact-checked on: $convert-data (legacy→FHIR) is not the plain $convert operation (which just swaps JSON/XML). And HAPI's hapi-fhir-converter is FHIR version-to-version conversion, not an HL7v2 tool.

3. Build: interface engine plus staging server

HL7v2 arrives MLLP-framed over TCP; the interface engine terminates that feed and emits FHIR. NextGen Connect / Mirth is the mid-market default, but note the licensing shift: recent versions moved to a commercial license, while older versions remained open-source. Check the current licensing terms before you standardize on it. The vendor-neutral community fork is Open Integration Engine (OIE). Rhapsody and Cloverleaf are the enterprise engines for HA, scale, and multi-tenancy. The engine choice rarely blocks FHIR; all three do it. The differentiators are licensing, scale, and in-house skill.

Stand up a staging FHIR server (HAPI FHIR JPA is the common OSS target, or a managed service) and load your profiles, value sets, and ConceptMaps. Convert each message to a transaction Bundle with internal urn:uuid: references and business identifiers on every resource, so the whole message lands atomically with references resolved.

4. Validate: CI gate against US Core

Wire the official HL7 FHIR Validator into CI so every transformed resource is checked against your pinned US Core profiles, and fail the build on profile errors. Then run conformance testing against staging: Inferno (MITRE, free, self-hosted, hosts the ONC (g)(10) test kit and the US Core Test Kit) is the default route; Touchstone (AEGIS, TestScript-based, powers Drummond's approved alternative path) is the managed option. Many shops use both.

5. Backfill, parallel-run, cut over

  • Backfill history into staging via Bulk $export (async, NDJSON) → ETL → import in InitialLoad mode. Reconcile counts in vs. out per resource type. No silent drops. Note $export returns current state only; resource history, Binary payloads, and non-standard extensions need separate handling.
  • Parallel run (shadow mode). Fork the live HL7v2 feed to the new pipeline without letting it drive downstream systems. Diff outputs against the legacy path (patient counts, encounter counts, result values), and let it soak days to weeks until the discrepancy rate is understood and acceptable. This is the step that de-risks the cutover and the one teams are tempted to skip. Don't skip it.
  • Cut over incrementally, one interface or message type at a time, keeping the legacy path warm as rollback. Switch the import to IncrementalLoad / enable the real-time feed, and keep reconciliation dashboards running. Decommission legacy interfaces only after the new path runs clean through at least one full business cycle (month-end, reporting runs).

The pitfalls that actually bite

Terminology is where projects bleed time. OBX-3 and DG1 carry local codes; US Core wants LOINC/SNOMED/RxNorm. A single "glucose" maps to dozens of LOINC codes by specimen, method, and timing, so mapping on display-name string match silently corrupts clinical meaning. Build governed maps with a clinician in the loop, always retain the source system+code as a secondary coding, and drive translation with a terminology server ($translate/ConceptMap) rather than embedding maps in transform code. Units are their own trap: v2 sends mg/dL, mg/dl, MG/DL; UCUM is case-sensitive, so validate against the grammar.

Identity. PID-3 repeats. Map every identifier with a correct, stable system URI per assigning authority. MRNs are unique only within an authority, so two facilities can issue the same number to different people. Reusing one authority's namespace for another silently merges distinct patients. Handle A40 merges with Patient.link (replaced-by) and re-point every dangling reference: cheap to ignore in a pilot, expensive in production.

ADT event ordering and idempotency. v2 feeds don't guarantee order; an A08 can arrive before its A01. Sequence by MSH-7 / EVN-2, not receipt order, and reject updates that predate current state. Derive deterministic resource IDs from natural keys (hash of assigning-authority + MRN for Patient, filler order number for DiagnosticReport) and use conditional create/update so replays and backfills converge instead of duplicating. Cancels (A11/A13) must reverse prior state, not append. Key Encounter by visit number (PV1-19) or you get encounter explosions.

Timezones and precision. v2 DTM frequently omits the offset. Guessing UTC when the source was local shifts events by hours and breaks ordering. Establish each facility's timezone (and DST behavior) per feed and apply it explicitly using IANA tzdata. Preserve partial precision. Map YYYYMMDD to a FHIR date; don't fabricate 00:00:00 plus an offset.

Reference integrity. Load referenced resources (Patient, Practitioner, Organization, Location) before the resources that point at them, or use transaction Bundles with urn:uuid references for atomic resolution. Providers arrive as XCN with different IDs across feeds, so build Practitioner/Organization/Location masters keyed by stable identifiers early or you fragment references into duplicates.

Vitals need the vital-signs profile. Blood pressure is not one Observation; it's a panel with systolic/diastolic components under LOINC 85354-9, bound to us-core-vital-signs. Naive OBX→Observation mapping fails US Core here.

No native ACK in REST. Replace MLLP+ACK with HTTP status plus OperationOutcome, and rebuild reliable delivery yourself: retries, dedup on MSH-10, ordering. FHIR gives you the payload model, not the messaging guarantees your interface engine provided.

Performance at volume. Historical backfill can be hundreds of millions of messages and dwarfs the live feed. Per-resource POSTs won't finish; use batch/transaction Bundles or $import, and make backfill jobs checkpointed, resumable, and shardable by patient/facility.

PHI everywhere. Messages, intermediate files, logs, and dead-letter queues all contain PHI. Scrub PHI from logs, encrypt end to end, and use synthetic data (Synthea) upstream of the controlled zone. Preserve consent and sensitivity. 42 CFR Part 2, HIV, and behavioral-health flags that v2 encoded in Z-segments must map to Consent and meta.security labels before loading into a broadly-queryable store.

The recurring theme: retain the raw v2 source (it's the ground truth for re-transforming when a mapping bug surfaces, and it will), use deterministic resource IDs, profile before you transform, and treat terminology and identity as architecture. Everything else is downstream of those four.

When to bring in help

A pilot that converts ADT to Patient and Encounter is a weekend. A production migration that survives out-of-order replays, governs identifier systems across facilities, maps thousands of local codes to LOINC and RxNorm without corrupting clinical meaning, and proves completeness through a parallel run is a program. The two workstreams that decide whether you ship merely-valid FHIR or genuinely interoperable data, terminology governance and identity architecture, are exactly the ones that look cheap early and overrun late.

1Putt Health builds these pipelines for health systems, payers, and EHR vendors, from feed profiling and segment mapping through US Core conformance and parallel-run cutover. If you want a second set of hands on the mapping, or a review before you cut over, get in touch.