Skip to main content

hl7v2-fhir-bridge

CI

Turn a radiology department's HL7 v2 feed into FHIR R4 without duplicating a patient, losing an order, or un-signing a report.

Hospitals will run HL7 v2 for decades. New systems, AI platforms included, want FHIR. This is a working reference implementation of the bridge between them for the radiology workflow. It receives ADT, orders (ORM/OMI) and results (ORU) over MLLP. Each message becomes one FHIR transaction Bundle of conditional requests, so a resend duplicates nothing and rolls nothing back. Every bundle is validated before it is sent, every resource is traced back to the message that wrote it, and the sender always gets an ACK it can act on.

flowchart LR
    RIS["RIS / reporting<br/>(HL7 v2)"] -->|"MLLP :2575"| B
    subgraph B["V2FHIR BRIDGE"]
      direction TB
      L["listener<br/>framing, per-connection order"] --> P["parse<br/>MSH-18 charset, escapes"] --> D["dedupe<br/>MSH-10 + content hash"]
      D --> M["map<br/>table-driven, conditional"] --> V["validate<br/>fhir.resources + bindings"]
    end
    V -->|"transaction Bundle"| F["FHIR R4 server"]
    V -->|"JSON"| DIR["data/bundles/"]
    B -->|"ACK AA / AE / AR"| RIS

Run it (about a minute)

Requires Python 3.11+.

git clone https://github.com/seth2177/hl7v2-fhir-bridge && cd hl7v2-fhir-bridge
python -m pip install -r requirements.txt        # Windows: py -3.12 -m pip install -r requirements.txt
python run_demo.py                               # Windows: py -3.12 run_demo.py

That one command starts a mock FHIR server and the MLLP listener. A simulated RIS and reporting system then send one patient's workflow over real TCP: A04 → ORM NW → ORM SC (exam complete) → ORU preliminary → final → corrected → A08 name change → a second order → its cancel. After that come two things interface engines really do: resend a message, and send garbage.

   #  step                               message   control id  ACK  FHIR  (+ created  ~ updated  = already there; +1 Provenance each)
   1  register outpatient                ADT^A04   RIS00001    AA   +Patient +Practitioner +Encounter
   2  new CT order                       ORM^O01   RIS00002    AA   =Patient =Practitioner =Encounter +ServiceRequest
   3  exam completed (SC/CM)             ORM^O01   RIS00003    AA   =Patient =Practitioner =Encounter ~ServiceRequest
   4  preliminary report                 ORU^R01   RPT00001    AA   =Patient =Practitioner =Encounter =ServiceRequest +ImagingStudy +Practitioner +DiagnosticReport
   5  final report                       ORU^R01   RPT00002    AA   =Patient =Practitioner =Encounter =ServiceRequest =ImagingStudy =Practitioner ~DiagnosticReport
   6  corrected report                   ORU^R01   RPT00003    AA   =Patient =Practitioner =Encounter =ServiceRequest =ImagingStudy =Practitioner ~DiagnosticReport
   7  name change (A08)                  ADT^A08   RIS00004    AA   ~Patient =Practitioner ~Encounter
   8  second order: MR brain             ORM^O01   RIS00005    AA   =Patient =Practitioner =Encounter +ServiceRequest
   9  cancel second order (ORC only)     ORM^O01   RIS00006    AA   ~ServiceRequest
  10  engine resends #2 (same MSH-10)    ORM^O01   RIS00002    AA   duplicate: acknowledged, not sent again
  11  garbage in an MLLP frame           ?         ?           AR   not an HL7 v2 message: must start with MSH, found 'thi'

[3/3] FHIR server state
  Patient           NÚÑEZ-GARCÍA, JOSÉ ANTONIO   MRN SYN100234   version 2
  ServiceRequest    ACC2001  CT CHEST W/O CONTRAST      status completed  version 2
  ServiceRequest    ACC2002  MRI BRAIN W/O CONTRAST     status revoked    version 2
  DiagnosticReport  preliminary -> final -> corrected   (ServiceRequest/4, ImagingStudy/5)
                    conclusion: IMPRESSION: CORRECTED: nodule is in the right LOWER lobe. Follow-up CT
  ImagingStudy      urn:oid:2.25.229850229598152031665758655...  status available
  Encounter 1, Practitioner 2, Provenance 9

Eleven messages end up as one patient (renamed, version 2), two orders (one completed, one cancelled) and one report with three versions, linked to its order and its imaging study. The patient appears in every message but was created once. The resend and the garbage were answered and changed nothing. The demo checks this end state itself and exits non-zero if it is wrong, and CI runs it on every push.

Other things to try:

python -m v2fhir convert samples/oru_r01_final.hl7      # one message -> the FHIR transaction Bundle (JSON)
python -m pip install -r requirements-dev.txt && python -m pytest -q
                                                        # parser, escapes, charsets, every mapping, MLLP over TCP,
                                                        # ACK content, idempotency, validation, fuzzing
python -m v2fhir.mock_fhir --port 8080                  # terminal 1: the mock FHIR server
python -m v2fhir serve --fhir-url http://127.0.0.1:8080/fhir   # terminal 2: the listener on :2575
python -m v2fhir send samples/omi_o23_new_stat.hl7      # terminal 3: send a file, print the ACK

python run_demo.py --fhir-url http://localhost:8080/fhir sends the same traffic to a real server instead, e.g. docker run -p 8080:8080 hapiproject/hapi:latest. I ran it against HAPI FHIR 8.12 (R4): the demo passes its own end-state check, a replay after a restart creates nothing and leaves the corrected report corrected, and the 404, 412 and escaping cases behave as they do on the mock. HAPI_URL=http://localhost:8080/fhir python -m pytest tests/test_hapi.py repeats that against an empty server. The one visible difference: HAPI answers 200 OK for both an update and a match, so the demo shows = where the mock shows ~.

What it handles, and why

Real-world problem How the bridge handles it Where
Engines resend messages, and a restart forgets what was seen MSH-10 + content-hash dedupe re-ACKs a resend; every FHIR request is conditional, and orders and reports are checked against the server before writing, so a resend after a restart does not duplicate or roll back anything (the same ORM twice gives one ServiceRequest) bridge.py, mapping/bundle.py
One control id reused for a different message (counter reset) processed, with a warning; dropping a real message is worse bridge.py
An assigning authority the bridge can't turn into a system (DNS, local, a mistyped OID), or none at all an unrecognised one still gets its own system (warning); with none, default_assigning_authority applies, else a patient key is AE 101, never a bare-value search (which in FHIR matches any system) mapping/datatypes.py, mapping/patient.py
PID-3 repeats with several assigning authorities the MRN is chosen by configured authority, then type MR; all identifiers kept; the match is on the chosen one mapping/patient.py
An old order's PID overwriting a newer A08 only ADT upserts demographics; orders and results create-if-absent mapping/patient.py
ORU arrives before its ORM the result creates the order; the late NW matches it and cannot re-open it mapping/results.py, orders.py
Placer, filler and accession numbers arrive in different subsets from different systems orders and reports match on any of them (identifier=a,b,c); two different orders matching is AE, not a guess, and so is one stored order whose placer, filler or accession of the same system has a different value (AE 205). An order or report that belongs to another patient (not linked by an A40) is AE too. Numbers are merged, never erased: a message carrying a subset keeps the others mapping/orders.py
Cancel for an order the server never saw ORC-only cancel: AE 204 (unknown key). Full cancel: recorded as revoked, AA with warning bridge.py
ORC-only cancel wiping the order status-only messages become a FHIRPath PATCH of status, not a PUT mapping/orders.py
An old result overwriting a newer report (a late preliminary after the final, a resent final after the correction, a queued cancel after the final) reads the current report first; a lower status never replaces a higher one, and at equal status the older OBR-22 loses; the stale entry is dropped with a warning bridge.py
A merge erased by the next A08, or a feed that hasn't seen the merge re-activating the old MRN ADT reads the current Patient first: links are kept and a merged-away record stays retired (warning) bridge.py
An old status message re-opening a finished order (a resent SC after the cancel) reads the current order first; completed and revoked are terminal, so the late status is dropped with a warning bridge.py
Timestamps without a time zone local time in default_timezone, DST-correct, with a warning; no zone configured → date only, never a made-up offset mapping/datatypes.py
Partial-precision timestamps (2026, 2026091514, 202609151430) a date keeps its precision; a time is zero-filled to hh:mm:ss, because FHIR has no hour- or minute-only time; an instant without a time is omitted mapping/datatypes.py
Non-ASCII names (José Núñez), MSH-18 declared charsets are decoded strictly: bytes that aren't valid in the declared set, or UTF-8 bytes under a single-byte declaration such as 8859/1, are AE, not a mangled name; undeclared: UTF-8, then cp1252 with a warning hl7/charset.py
\r\n or \n instead of \r; a bare LF inside report text all three terminators accepted; a bare LF in a CR message is data, not a segment hl7/parser.py
Empty fields, trailing separators, "" explicit nulls, padded values trailing separators harmless; values stripped. An ADT is applied as a snapshot, so an empty PID field removes the element just as "" does (documented deviation, MAPPING §6) hl7/parser.py, mapping/patient.py
Escapes and formatted text (\F\ \S\ \.br\ \H\ \Xhh\); unescaped ^ in report text escape handling per v2.5.1 2.7 (formatting commands mapped to line breaks and spaces, with caps); report text read whole, so a stray ^ survives hl7/escape.py, mapping/results.py
Z-segments (and OMI's IPC) kept verbatim on the message's Provenance; ZDS-1 becomes the ImagingStudy UID (IHE convention) mapping/provenance.py
Malformed message AR with ERR (location and HL7 0357 code); MSA-2 recovered even when the message does not parse hl7/ack.py
Split frames, several messages per read, noise between frames, hang-ups a streaming frame decoder (linear even on a flood of start blocks); per-connection ordering; idle timeout on reads and ACK writes; a connection limit (max_connections) at which the quietest idle connection is dropped. A stalled or silent peer is dropped, never the listener mllp.py
Huge message 2 MB reports pass in linear time; above max_message_bytes only 4 KB are kept and an AR carries the right control id mllp.py
A training or test feed (MSH-11 T or D) pointed at production AR 202, never written; accepted_processing_ids says which ids this bridge takes bridge.py
Unsupported events (A03, SIU…) blocking the sender's queue AA + warning by default, AR if configured bridge.py
Generated FHIR that is subtly wrong every bundle validated before it leaves: fhir.resources models, required value sets, and the invariants this mapper could break (per-1, prr-1, bdl-7, no empty elements, 1 MB strings); invalid → AE, never sent. Other FHIR invariants and profiles are not checked validate.py
FHIR server down or refusing down (connection errors, 5xx): retried with backoff, then AR (v2.5.1 2.9.2.2: rejected for reasons unrelated to content, such as system down) (transient_failure_ack = "AE" for engines that only queue on AE). Refused (4xx): AE, not retried. Every ACK leaves within ack_deadline_seconds (default 25 s) bridge.py, sink.py
MSH-10 used in a file name sanitised: ../../etc/passwd is data sink.py
"Which message wrote this?" meta.source on every version (the message that last created or replaced it) plus one Provenance per message (what it asserted, MSH-10, sender, Z-segments) mapping/provenance.py

Every field and code mapping: docs/MAPPING.md · One order and its report, hop by hop (with a FHIR primer for v2 people): docs/HOW-IT-WORKS.md

Layout

v2fhir/          the bridge
  hl7/           parser, escapes, MSH-18 character sets, ACK builder
  mapping/       v2 -> FHIR: tables.py (every code map), datatypes, patient/encounter, orders, results, provenance
  mllp.py        frame decoder, asyncio listener, client
  bridge.py      parse -> dedupe -> map -> validate -> sink -> ACK; never raises
  validate.py    fhir.resources, required bindings, and the invariants the mapper could break
  sink.py        bundle directory and FHIR transaction POST (httpx)
  mock_fhir/     in-memory FHIR server that executes transactions (conditional create/update/patch, OR search)
  tools/         ris_sim.py: synthetic RIS and reporting system
samples/         synthetic messages: ADT A04/A08/A40, ORM NW/SC/CA, OMI^O23, ORU (TX and FT), a v2.3 Latin-1 feed
config/          bridge.toml: MRN authority, identifier systems, time zone, limits
tests/           unit, TCP, end to end, fuzzing
run_demo.py      the whole workflow in one command

Quality

  • 304 tests, plus ruff, run in CI on Linux and Windows with Python 3.11 and 3.12. CI also runs the demo, which checks its own end state.
  • Every bundle the bridge produces is validated against the FHIR models (fhir.resources 8.3, R4B classes), against the required value sets the models don't enforce, and against the few FHIR invariants this mapper could break. It is not full conformance validation: that needs the HL7 validator or a server's $validate.
  • Idempotency and ordering are tested against a server that really executes the transaction: conditional create, update and patch, reference rewriting, all-or-nothing rollback, 404/412.
  • Adversarial testing, first round: I fuzzed the parser, mapper and bridge with 120,000 seeded mutated and random messages (FUZZ_N=120000 python -m pytest tests/test_adversarial.py -k fuzz_parser reruns it; every suite run does 3,000), and the listener with concurrent random bytes, truncated frames and oversize frames. It found control bytes from MSH-3 echoed into the ACK (a 0x1C there breaks the sender's MLLP framing), a garbage segment id echoed into ERR-2 (splitting the ERR segment), and NUL accepted as a field separator.
  • Adversarial testing, second round: I checked the output against the FHIR R4 spec, HL7 v2.5.1 chapter 2 and the v2-to-FHIR IG ConceptMaps, and attacked the listener and the ordering logic directly. That found the serious bugs: a patient identifier without a system that matched any patient, a resent final report that replaced the correction, one order number shared by two patients' orders, a 16-byte \.sp escape that could exhaust memory, a run of 0x0B bytes that stalled every connection, and code maps that didn't match the IG. Every fix has a regression test that fails on the old code.

Scope and safety

Message subset. ADT^A01/A04/A08/A40, ORM^O01, OMI^O23 and ORU^R01, in HL7 v2.3–2.5.1, for the radiology workflow. It is not a certified interface engine. There is no general routing, no queueing or persistence of its own (the sender's queue and the AR/AE resend contract are the durability), no enhanced-mode acknowledgements, and no batch (FHS/BHS) over MLLP. Text OBX becomes the report; numeric OBX are not mapped to Observations.

Known limits. The dedupe cache is in memory. Encounters are matched on the visit number alone, so a site that reuses visit numbers across facilities needs a per-facility system. The checks that read orders, reports and patients before writing (stale messages, number conflicts, wrong patient, merges) and the unknown-order warning need the FHIR server (the directory sink alone can't know server state). The bridge does its own FHIR work one message at a time, so its connections can't race each other, but another system writing the same resources at the same moment can, and so can concurrent conditional creates on some servers. A40 writes both patient records rather than calling a server merge operation. The requests have run against the mock and HAPI FHIR 8.12, not yet against a vendor server (Azure, Google, Epic).

Security. The MLLP listener has no TLS and no authentication, like most MLLP in hospitals. It binds to 127.0.0.1 by default and belongs on a segmented interface network. To add TLS, pass an ssl.SSLContext to asyncio.start_server in MLLPServer.start(). For mTLS, create it with ssl.create_default_context(ssl.Purpose.CLIENT_AUTH), load_cert_chain() for the bridge's certificate, load_verify_locations() for the CA that issues sender certificates, and verify_mode = ssl.CERT_REQUIRED. Then check the peer certificate's subject against an allow-list of sending applications. Alternatively, terminate TLS in front of it (stunnel, or the interface engine's own TLS). On the FHIR side it POSTs without credentials. A real deployment would use SMART Backend Services (OAuth2 client credentials with a signed JWT) through an httpx.Auth. Bundles on disk contain PHI and need the same protection as the FHIR server.

This is a demonstration and reference build, not a medical device. All patients, providers, identifiers, facilities and UIDs in the repo are synthetic (SYNTH_ prefixes, 555-01xx phone numbers, 2.25 UIDs, example.org systems). Don't point it at production PHI without a formal review.


Built by Seth Turnbo: 23 years on MRI/CT (GE, Philips, Siemens), multi-vendor DICOM/HL7/PACS integration. LinkedIn

Metadata

Release files for hl7v2-fhir-bridge 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hl7v2-fhir-bridge 0.1.0
File Size Uploaded
hl7v2_fhir_bridge-0.1.0.tar.gz 72.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hl7v2-fhir-bridge 0.1.0
File Interpreter ABI Platform
hl7v2_fhir_bridge-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 158.4 kB

Release files / hl7v2_fhir_bridge-0.1.0.tar.gz

Download URL hl7v2_fhir_bridge-0.1.0.tar.gz
Size 72.8 kB
Tags Source
SHA-256 checksum
How to use checksums
c2b5137f3ce2164647678ec70d7f6d5e2774042d7eb3ac14237a8e2293500951
BLAKE2b-256 checksum
How to use checksums
875174ec0022da2335d6579a84f5c83dd55a4028a2856f5a345d41aeefa6ef0b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / hl7v2_fhir_bridge-0.1.0-py3-none-any.whl

Download URL hl7v2_fhir_bridge-0.1.0-py3-none-any.whl
Size 85.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a64bc08d7862715beedae8bb8902fb0e7b19350db3cd6ad9ade445525146659c
BLAKE2b-256 checksum
How to use checksums
1ba4e506d58c6b9ba5ea480178b74aac6a1cce0e3562ce432373ab697aaf5ea3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page