hl7v2-fhir-bridge
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.resources8.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_parserreruns 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 (a0x1Cthere 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
\.spescape that could exhaust memory, a run of0x0Bbytes 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)
| File | Size | Uploaded | |
|---|---|---|---|
| hl7v2_fhir_bridge-0.1.0.tar.gz | 72.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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