FHIR Bundle Transaction Processing: Atomic Writes and Referential Integrity in Clinical ETL

Writing several related FHIR resources — a Patient, an Encounter, and the Observation results that hang off both — as a single all-or-nothing unit is the difference between a warehouse that never holds a half-written clinical record and one that quietly accumulates orphaned references. A FHIR transaction Bundle is the mechanism the R4 specification (version 4.0.1) provides for exactly this: submit a set of interdependent writes in one HTTP POST to the server root, and either every entry commits or the server rolls the whole set back. Within the FHIR & HL7 v2 Standards Architecture for Clinical ETL, this page addresses the transformation-tier sub-problem of turning a graph of parsed resources into a durable, referentially intact write — how transaction semantics differ from batch, how urn:uuid: placeholders let you reference a resource whose server id does not yet exist, and how to parse the response so your pipeline learns the ids the server assigned. The companion page on handling referential integrity in FHIR transaction bundles drills into the reference-resolution rules in depth.

Prerequisites & Context

This page assumes the resources are already parsed and validated; the transaction is the write, not the parse. Before implementing the patterns below, confirm you have:

  • A FHIR server that supports transactions. Not all do. Check the server’s CapabilityStatement (GET [base]/metadata) for rest.interaction containing transaction. HAPI FHIR, Google Cloud Healthcare API, Azure Health Data Services, and Microsoft/Firely servers support it; some lightweight or read-optimized facades support batch only.
  • Parsed, schema-valid resources in memory as dicts or fhir.resources model instances — a transaction rejects on the first invalid resource, so validate structurally before you build the Bundle.
  • An idempotency strategy. A transaction is atomic but not automatically idempotent: a naive retry after a network timeout can double-write. You need either conditional operations (If-None-Exist) or a deterministic business identifier so a replay is a no-op. This mirrors the broader idempotent clinical data load pattern used across the warehouse.
  • A grasp of how resources reference each other — the directed graph of subject, encounter, performer, and similar Reference fields described in the FHIR resource hierarchy. You cannot write a graph atomically if you do not know its edges.
  • A requests session (or equivalent HTTP client) with the correct Content-Type: application/fhir+json header and, in production, OAuth2/SMART bearer tokens.

If transactions are unavailable, do not simulate atomicity in application code by issuing individual writes and compensating on failure — that reintroduces exactly the partial-write hazard transactions exist to remove. Fall back to batch only where the entries are genuinely independent.

Concept & Spec Detail: Transaction Is a Contract, Batch Is a Convenience

A FHIR Bundle is a container resource whose Bundle.type field selects its processing semantics. The two interaction types relevant here — transaction and batch — share a wire shape but obey opposite failure contracts. A transaction is processed as a single atomic unit: the server resolves all internal references, applies every entry, and if any entry fails, it rolls back the entire set and returns a single error OperationOutcome with an appropriate 4xx/5xx status. A batch is a bag of independent requests: each entry is applied on its own, one entry’s failure has no effect on the others, and the response Bundle carries a per-entry response.status so you learn each outcome individually.

Atomic processing of a FHIR transaction Bundle with urn:uuid references A transaction Bundle enters with two entries: a Patient whose fullUrl is a urn:uuid placeholder carrying an If-None-Exist conditional-create header, and an Observation whose subject reference points at that same urn:uuid. The server first resolves the internal reference, then runs conditional-create deduplication which either finds an existing Patient or assigns a new id. All entries then commit atomically in one database transaction, rewriting the placeholder to the server-assigned id; if any entry fails the whole set rolls back and routes to a dead-letter queue. The response Bundle returns each entry's assigned location. transaction Bundle Patient entry fullUrl: urn:uuid:P1 method: POST ifNoneExist: identifier=MRN|A19 Observation entry fullUrl: urn:uuid:O1 method: POST subject.reference: urn:uuid:P1 refers to 1 · resolve map urn:uuid refs build dependency order 2 · conditional If-None-Exist dedup Patient: reuse or assign id 3 · atomic commit DELETE, POST, PUT/PATCH, GET rewrite urn:uuid → Patient/8801 all-or-nothing in one DB transaction 201 response transaction- response Bundle entry.location ids any failure rollback → DLQ single OperationOutcome · nothing persisted
A transaction Bundle: internal urn:uuid references are resolved, conditional creates deduplicate the Patient, then every entry commits in one database transaction — or the whole set rolls back to the dead-letter queue.

Transaction versus batch semantics

The choice is not stylistic. If the entries depend on one another — an Observation that must reference a Patient created in the same submission — only a transaction guarantees the reference will resolve and the pair will land together.

Property transaction batch
Bundle.type value transaction batch
Atomicity All-or-nothing; any failure rolls back every entry None; each entry independent
Internal reference resolution urn:uuid: placeholders resolved across entries before commit Not resolved across entries; references must be literal
Failure response One error OperationOutcome, one HTTP status for the whole Bundle Per-entry response.status; partial success expected
Processing order Server-imposed (DELETE → POST → PUT/PATCH → GET), not entry order Undefined; entries independent
Best for Interdependent clinical writes (Patient + Encounter + Observations) Independent, unrelated operations submitted together for efficiency
Retry safety Combine with conditional ops for idempotency Retry individual failed entries

The critical, frequently-missed rule: client entry order does not determine execution order in a transaction. The server applies entries by request method in a fixed sequence — DELETE first, then POST (create), then PUT/PATCH (update), then GET/read — regardless of the order you placed them in Bundle.entry. This is why you must never rely on “put the Patient first so it exists when the Observation is written.” The server resolves the reference graph independently of array position; ordering safety comes from urn:uuid: resolution, not from sequencing your entries by hand.

Request methods and conditional headers

Each Bundle.entry.request object carries the method and, for conditional behavior, either an ifNoneExist (conditional create) or ifMatch/ifNoneMatch field. These map to the HTTP headers the server would see on a standalone request.

request field Value / example Effect
method: POST + url: "Patient" create Server assigns a new id; use with fullUrl = urn:uuid:… for internal references
method: PUT + url: "Patient/123" update / upsert Creates or replaces the resource at that id
method: DELETE + url: "Observation/9" delete Removed first in processing order
method: PATCH + url: "Patient/123" partial update Applies a JSON Patch / FHIRPath Patch body
ifNoneExist: "identifier=MRN|A19" conditional create If exactly one match exists, reuse it (no create); if none, create; if many, error
ifMatch: "W/\"3\"" conditional update Update only if ETag/versionId matches — optimistic concurrency
ifNoneMatch: "*" conditional create-on-PUT Create only if the target does not already exist

The ifNoneExist value is a search query string (the part after ? on a standalone conditional create). identifier=http://hospital.example/mrn|A19 says “only create this Patient if no Patient already has that medical record number.” Combined with a transaction, it is the primary tool for making a repeated write idempotent: a retried Bundle finds the existing Patient and reuses its id rather than creating a duplicate.

Implementation

The engine decomposes into four steps: build the transaction Bundle with urn:uuid: cross-references, attach a conditional create to the Patient, POST it and handle the transaction-level outcome, then parse the response to map each placeholder to its server-assigned id. Each step has a validation gate.

Step 1 — Build the transaction Bundle with urn:uuid cross-references

The pattern that makes atomic multi-resource writes possible is placeholder identity. Each entry gets a fullUrl set to a fresh urn:uuid: value, and any resource that must reference another entry uses that same urn:uuid: string in its reference field. The server treats these as local placeholders and rewrites them to real ids on commit.

import uuid

SNOMED_URI = "http://snomed.info/sct"
MRN_SYSTEM = "http://hospital.example/mrn"


def new_placeholder() -> str:
    """Return a fresh urn:uuid placeholder for a Bundle entry fullUrl."""
    return f"urn:uuid:{uuid.uuid4()}"


def build_transaction_bundle(mrn: str, family: str, given: str,
                             loinc_code: str, value: float,
                             unit: str) -> dict:
    """Assemble a transaction Bundle: one Patient and one Observation
    that references it through a urn:uuid placeholder.

    The Observation.subject.reference and the Patient.fullUrl share the
    same urn:uuid string, so the server resolves the link atomically.
    """
    patient_url = new_placeholder()
    observation_url = new_placeholder()

    patient_entry = {
        "fullUrl": patient_url,
        "resource": {
            "resourceType": "Patient",
            "identifier": [{"system": MRN_SYSTEM, "value": mrn}],
            "name": [{"family": family, "given": [given]}],
        },
        "request": {
            "method": "POST",
            "url": "Patient",
            # Conditional create: reuse an existing Patient with this MRN.
            "ifNoneExist": f"identifier={MRN_SYSTEM}|{mrn}",
        },
    }

    observation_entry = {
        "fullUrl": observation_url,
        "resource": {
            "resourceType": "Observation",
            "status": "final",
            "code": {"coding": [{"system": "http://loinc.org",
                                  "code": loinc_code}]},
            # The cross-reference: same urn:uuid as the Patient fullUrl.
            "subject": {"reference": patient_url},
            "valueQuantity": {"value": value, "unit": unit,
                              "system": "http://unitsofmeasure.org"},
        },
        "request": {"method": "POST", "url": "Observation"},
    }

    return {
        "resourceType": "Bundle",
        "type": "transaction",
        "entry": [patient_entry, observation_entry],
    }

Validation gate: assert that every reference value pointing at a co-submitted resource matches some entry’s fullUrl exactly, and that no two entries share a fullUrl. A reference that names a urn:uuid: no entry defines will fail resolution and roll back the entire transaction — catch it before the POST, not from the 400 response.

Step 2 — Post the Bundle to the server root and handle the outcome

A transaction Bundle is submitted with a single POST to the server base URL (not to a resource-type endpoint). A 2xx status means the whole set committed; any 4xx/5xx means nothing persisted and the body is a single OperationOutcome.

import requests


class TransactionError(RuntimeError):
    """Raised when a FHIR transaction rolls back; carries the OperationOutcome."""

    def __init__(self, status: int, outcome: dict):
        self.status = status
        self.outcome = outcome
        issues = "; ".join(
            f"{i.get('severity')}: {i.get('diagnostics', i.get('code'))}"
            for i in outcome.get("issue", [])
        )
        super().__init__(f"Transaction failed ({status}): {issues}")


def post_transaction(base_url: str, bundle: dict,
                     session: requests.Session,
                     timeout: float = 30.0) -> dict:
    """POST a transaction Bundle to the FHIR server root.

    Returns the transaction-response Bundle on success. On any non-2xx,
    parses the transaction-level OperationOutcome and raises.
    """
    resp = session.post(
        base_url.rstrip("/") + "/",
        json=bundle,
        headers={"Content-Type": "application/fhir+json",
                 "Accept": "application/fhir+json"},
        timeout=timeout,
    )
    body = resp.json() if resp.content else {}
    if not resp.ok:
        # A rolled-back transaction returns a single OperationOutcome.
        if body.get("resourceType") == "OperationOutcome":
            raise TransactionError(resp.status_code, body)
        raise TransactionError(resp.status_code,
                               {"issue": [{"severity": "error",
                                           "code": "exception",
                                           "diagnostics": resp.text[:500]}]})
    return body

Validation gate: confirm the success body has resourceType == "Bundle" and type == "transaction-response", and that len(response["entry"]) == len(request_bundle["entry"]). A mismatch means the server silently degraded the transaction to a batch — treat that as a hard failure, because you no longer have an atomicity guarantee.

Step 3 — Parse the response and map placeholders to assigned ids

The transaction-response Bundle returns entries in the same order as the request. Each entry.response.location (or entry.fullUrl) carries the server-assigned id. Building the placeholder-to-id map lets downstream code persist the real references your warehouse needs.

import re

_LOCATION_RE = re.compile(r"(?P<type>[A-Za-z]+)/(?P<id>[^/]+)(?:/_history/.*)?$")


def map_placeholders_to_ids(request_bundle: dict,
                            response_bundle: dict) -> dict:
    """Correlate request urn:uuid placeholders with server-assigned ids.

    Response entries are returned positionally aligned with the request,
    so we zip them and read response.location for the committed id.
    Returns {urn:uuid -> "ResourceType/id"}.
    """
    resolved: dict[str, str] = {}
    req_entries = request_bundle["entry"]
    resp_entries = response_bundle["entry"]
    if len(req_entries) != len(resp_entries):
        raise ValueError("Response entry count does not match request")

    for req, resp in zip(req_entries, resp_entries):
        placeholder = req.get("fullUrl")
        location = resp.get("response", {}).get("location", "")
        status = resp.get("response", {}).get("status", "")
        m = _LOCATION_RE.search(location)
        if placeholder and m:
            resolved[placeholder] = f"{m['type']}/{m['id']}"
        # A 200 (vs 201) on a conditional-create entry means the server
        # reused an existing resource rather than creating a new one.
        resp["_created"] = status.startswith("201")
    return resolved

A 201 Created status on the Patient entry means the conditional create wrote a new record; a 200 OK means ifNoneExist matched an existing Patient and the server reused it. Both outcomes give you a usable id, and both leave the Observation correctly linked — that is the idempotency payoff.

Validation gate: assert every request fullUrl appears as a key in the resolved map and that no resolved value still contains urn:uuid:. An unresolved placeholder means the server did not rewrite a reference and your persisted Observation would point at nothing.

Step 4 — Assemble the durable, provenance-bearing write record

With the placeholder map in hand, rewrite any in-memory references to the committed ids and emit an audit record covering the transaction as a single unit.

import hashlib
import json


def build_write_record(request_bundle: dict, resolved: dict,
                       correlation_id: str) -> dict:
    """Produce one audit record for the whole atomic transaction."""
    canonical = json.dumps(request_bundle, sort_keys=True).encode("utf-8")
    return {
        "correlation_id": correlation_id,
        "bundle_type": request_bundle["type"],
        "entry_count": len(request_bundle["entry"]),
        "assigned_ids": sorted(resolved.values()),
        "bundle_sha256": hashlib.sha256(canonical).hexdigest(),
        "committed": True,  # only reached when post_transaction succeeded
    }

Validation gate: the audit record must describe the transaction atomically — one correlation_id, one hash, the full set of assigned ids — never one row per entry. A per-entry audit trail cannot represent “these resources committed together,” which is the exact fact a clinical auditor needs.

Edge Cases & Vendor Deviations

The specification is precise; server implementations diverge in the places that matter most for a production loader.

Scenario Deviation Mitigation
Server lacks transaction support Facade advertises only batch in its CapabilityStatement Detect at startup from rest.interaction; refuse to fall back silently — a batch has no atomicity, so require an explicit config flag
Reference resolution failure An entry references a urn:uuid: that no entry defines, or a literal id that does not exist Whole transaction rolls back with a not-found/invalid OperationOutcome; validate the reference graph before POST
Conditional-create race Two concurrent Bundles both pass ifNoneExist and create duplicate Patients Enforce a unique constraint on the identifier at the server; on 412/conflict, retry the transaction so the loser reuses the winner’s id
200-entry limit Some servers (and Google Cloud Healthcare) cap entries per transaction (commonly ~200–500) Chunk large graphs along dependency boundaries; keep each interdependent subgraph within one transaction
HAPI FHIR Processes DELETE/POST/PUT/GET in spec order and resolves urn:uuid fully; supports ifNoneExist Reliable baseline; still verify transaction-response type on every call
Azure Health Data Services Full transaction support, but stricter profile validation may reject on $validate rules a permissive server accepts Run resources through $validate in CI against the same profiles the server enforces
Google Cloud Healthcare API Supports transactions but historically capped bundle size and had nuances around conditional references Respect the documented entry cap; test conditional-reference syntax against the specific API version
Mixed methods in one Bundle A DELETE and a POST touching the same logical resource Remember server processing order (DELETE before POST) — a delete-then-create in one transaction is legal and ordered, but relying on entry position is not

The referential-integrity failure modes — dangling references, cyclic dependencies, and conditional references that resolve to zero or many targets — are involved enough to warrant their own treatment in handling referential integrity in FHIR transaction bundles. For choosing between transactional REST writes and analytical bulk loads at all, the FHIR REST vs bulk data export comparison sets the throughput context, and coded fields inside the Bundle still need validation via the FHIR terminology server integration before you commit them.

Compliance Note: A Partial Clinical Write Is a Data-Integrity Hazard

Atomicity is not merely an engineering nicety here; it is a patient-safety and compliance control. Under the HIPAA Security Rule’s integrity standard (45 CFR §164.312©) and ONC certification expectations, clinical data must be complete and accurate at rest. A partially-applied write — a Patient created but its Observation results lost, or an Encounter written without the diagnoses that justify it — produces a record that is plausibly complete but clinically wrong, the most dangerous kind of data-integrity defect because it passes downstream schema validation and surfaces only at the point of care or claim adjudication. The transaction contract exists precisely to make that state unrepresentable.

Two concrete obligations follow. First, audit the transaction as one unit. The audit trail must record that a defined set of resources committed together under one correlation_id, with a single lineage hash over the submitted Bundle — not a scatter of independent per-resource log lines that cannot prove the write was atomic. Second, treat the rollback path as a PHI store. When a transaction fails, the Bundle you route to a dead-letter queue for reprocessing contains full PHI, so that queue inherits the same encryption, access-control, and retention obligations as the primary warehouse. Log the transaction-level OperationOutcome and the Bundle’s SHA-256, never the raw resource bodies, in plaintext telemetry. A DLQ or trace span treated as “just errors” is a common and serious exposure.

Troubleshooting

My Observation committed but its subject reference is null or points at a urn:uuid. What went wrong?

The server did not resolve the placeholder. Either the Observation’s subject.reference string does not byte-for-byte match the Patient entry’s fullUrl, or the Bundle was processed as a batch (which does not resolve internal references) rather than a transaction. Confirm Bundle.type is transaction, confirm the response type is transaction-response, and assert the two urn:uuid: strings are identical before you POST.

A retry after a timeout created a duplicate Patient. How do I make the transaction idempotent?

A transaction is atomic but not automatically idempotent — if the first attempt actually committed before the network dropped the response, a blind retry writes again. Add a conditional create to the Patient entry with ifNoneExist keyed on a stable business identifier such as the MRN (identifier=http://hospital.example/mrn|A19). On retry the server matches the existing Patient, returns 200 instead of 201, and reuses its id, so the whole Bundle becomes a safe no-op.

I ordered my entries Patient-first, but the server still says the reference is unresolved. Does entry order matter?

No. In a transaction the server applies entries by request method in a fixed order — DELETE, then POST, then PUT/PATCH, then GET — not by their position in Bundle.entry. Reference resolution is driven by matching urn:uuid: placeholders across the whole Bundle, not by sequencing. Fix the placeholder strings; do not try to fix ordering by rearranging the array.

The server returned 200 OK for my whole POST but individual entries failed. Was it atomic?

That is the signature of a batch, not a transaction. A batch returns an overall 200 with per-entry response.status where some entries can fail while others succeed. A true transaction returns one status for the whole Bundle and a transaction-response type. If you needed atomicity, your Bundle.type was wrong or the server silently degraded it — verify the response type field and treat anything other than transaction-response as a failed atomic write.

My conditional create fails with a "multiple matches" error. Why, and how do I fix it?

ifNoneExist requires the search to match zero or one resource. If the identifier query matches several existing Patients, the server cannot decide which to reuse and rejects the entry — rolling back the transaction. This means your identifier is not actually unique in the server (duplicate MRNs already exist). Deduplicate the existing records first, and enforce a uniqueness constraint on the identifier so the ambiguity cannot recur.