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-1carriesAA/AE/ARandMSA-2echoes the originalMSH-10control 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:
idvs.identifier.Resource.idis the server-assigned technical key. Your v2 MRN/SSN lands in the business identifier (Patient.identifierwith asystemURI). Never map an MRN toResource.id.- CodeableConcept. Coded values carry
system+code+display. This is the target forOBX-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 message | Trigger | FHIR R4 resource(s) |
|---|---|---|
| ADT (A01/A03/A04/A08…) | admit/discharge/register/update | Patient + Encounter (+ Condition from DG1, Coverage from IN1, RelatedPerson from NK1) |
| ADT A40 | patient merge | Patient with link (replaced-by / replaces) |
| ORU^R01 | results | Observation (per OBX) grouped by DiagnosticReport (per OBR); Specimen from SPM |
| ORM / OMG^O19 / OML^O21 | orders | ServiceRequest |
| OMP^O09 / RDE | pharmacy order | MedicationRequest |
| RAS^O17 | med administration | MedicationAdministration (RXA in a RAS message) |
| VXU^V04 | immunization update | Immunization (RXA in a VXU message) |
| SIU^S12… | scheduling | Appointment (+ Slot, Schedule) |
| MDM^T02 | document | DocumentReference (+ Binary) |
| DFT^P03 | charges | ChargeItem / Account (+ Invoice); not Claim, which models payer adjudication |
| BAR | account/billing | Account / Coverage |
Segment → resource
| Segment | FHIR resource |
|---|---|
| MSH | MessageHeader (only in FHIR "message" Bundles); otherwise metadata/provenance |
| EVN | EVN-1 trigger informs Encounter.status; EVN-2/EVN-6 feed Provenance |
| PID | Patient |
| NK1 | RelatedPerson / Patient.contact |
| PV1 / PV2 | Encounter |
| DG1 | Condition (encounter diagnosis) |
| AL1 | AllergyIntolerance |
| OBR | DiagnosticReport (+ ServiceRequest for the order) |
| OBX | Observation |
| ORC | ServiceRequest / order metadata |
| RXO/RXE/RXA | MedicationRequest; RXA → MedicationAdministration (RAS) or Immunization (VXU), context-dependent |
| IN1/IN2 | Coverage (+ Organization for payer) |
| GT1 | Account.guarantor / RelatedPerson |
| SCH + AIx | Appointment |
| SPM | Specimen |
| NTE | .note on the parent resource |
PID → Patient, field level
| PID field | Patient element | Notes |
|---|---|---|
| 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-7 | birthDate | truncate DTM to YYYY-MM-DD |
| PID-8 | gender | M/F/O/U → male/female/other/unknown. Administrative gender only, not birth sex or gender identity |
| PID-10 / PID-22 | extension | US Core Race / Ethnicity (OMB/CDC value sets) |
| PID-11 (XAD) | address[] | line/city/state/postalCode/country; XAD.7 → address.use |
| PID-13/14 | telecom[] | set system (phone/email) + use |
| PID-16 | maritalStatus | |
| PID-19 (SSN) | identifier | system = http://hl7.org/fhir/sid/us-ssn, type = SS |
| PID-29/30 | deceased[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 field | Encounter element | Notes |
|---|---|---|
| PV1-2 | class | I→IMP, O→AMB, E→EMER (v3 ActEncounterCode) |
| PV1-3 (PL) | location.location | point-of-care^room^bed^facility → a Location hierarchy |
| PV1-7 / PV1-8 / PV1-9 | participant | attending (ATND) / referring (REF) / consulting (CON) → PractitionerRole |
| PV1-19 | identifier | visit number, a business identifier, not id |
| PV1-36 | hospitalization.dischargeDisposition | |
| PV1-44 / PV1-45 | period.start / .end | admit/discharge |
| ADT trigger + PV1-45 | status | A01→in-progress, A03→finished, A11→cancelled |
OBX → Observation, field level
| OBX field | Observation element | Notes |
|---|---|---|
| OBX-2 | drives value[x] | NM→valueQuantity, ST/TX→valueString, CE/CWE→valueCodeableConcept, SN→valueQuantity/Range |
| OBX-3 | code | LOINC in system=http://loinc.org; keep local code as a second coding |
| OBX-5 | value[x] | per OBX-2 |
| OBX-6 | valueQuantity.unit/.code | UCUM in system=http://unitsofmeasure.org |
| OBX-7 | referenceRange | low/high/text |
| OBX-8 | interpretation | H/L/N/A/HH/LL → v3 ObservationInterpretation |
| OBX-11 | status | F→final, P→preliminary, C→corrected, X→cancelled |
| OBX-14 (else OBR-7) | effective[x] | observation datetime |
| OBX-4 | groups components | multi-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-dataoperation (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$exportreturns 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.