Handling Referential Integrity in FHIR Transaction Bundles

A FHIR R4 (4.0.1) transaction Bundle is an all-or-nothing write: the server processes every entry as a single atomic unit, and one unresolved reference rolls back the entire batch. That makes referential integrity — every Observation.subject, Encounter.subject, and Condition.subject pointing at a resource that actually resolves — the property you must prove before you POST, not diagnose from a 400 afterward. This page is the runnable companion to FHIR bundle transaction processing: it covers exactly how the server links entries to each other, how placeholder identities become server-assigned literal IDs on commit, and how to catch a dangling reference in your own pipeline while you can still fix it cheaply.

Inside a transaction, references never resolve by guesswork. Each entry carries a fullUrl, and any reference that matches another entry’s fullUrl is treated as an intra-bundle link. When that fullUrl is a urn:uuid: placeholder, the server assigns a real logical ID at commit time and rewrites every reference to the placeholder in lockstep. A reference that matches neither an entry fullUrl, an existing server resource, nor a resolvable conditional query is dangling — and dangling references are the single most common cause of a rejected transaction.

Reference forms in a transaction Bundle (quick reference)

The table below is the lookup artifact to keep next to your bundle builder. It maps each reference form to what the server resolves it against and the failure mode you get when resolution fails.

Reference form Resolves to Failure mode
urn:uuid:<uuid> matching an entry fullUrl The server-assigned literal ID minted for that entry on commit Bundle rejected if no entry has that fullUrl (dangling placeholder)
Relative literal Patient/123 An existing resource already on the server 404 / processing error if Patient/123 does not exist
Absolute https://ehr.example/fhir/Patient/123 A resource on the named server (often the same server) Rejected if unresolvable or cross-server and unsupported
Conditional `Patient?identifier=MRN 998` The single resource matching the search query at commit
#contained-id A contained resource inside the same resource Broken if no matching contained.id is present

The two forms that dominate ETL output are the urn:uuid: placeholder (for resources you are creating in this same bundle) and the conditional reference (for resources that may already exist server-side, like a Patient keyed by MRN). Everything else is a literal you already know resolves.

Placeholder resolution in a FHIR transaction Bundle A data-flow diagram. A transaction Bundle holds a Patient entry with fullUrl urn:uuid:aaa and an Observation entry whose subject references urn:uuid:aaa. A resolver walks the reference graph and builds a placeholder-to-id map. On atomic commit the server mints literal IDs Patient/501 and Observation/902 and rewrites the Observation subject to Patient/501. A separate Observation whose subject references urn:uuid:zzz, which no entry declares, is a dangling reference that fails the whole transaction and is routed to a dead-letter queue. Transaction Bundle · placeholder → resolve → assigned literal ID transaction Bundle Patient entry fullUrl urn:uuid:aaa POST Patient Observation entry subject → urn:uuid:aaa POST Observation Observation entry subject → urn:uuid:zzz no entry declares zzz reference resolver walk graph · build uuid→id map atomic commit · literal IDs urn:uuid:aaa → Patient/501 Observation → Observation/902 Observation/902.subject rewritten → Patient/501 dead-letter queue whole transaction rejected dangling urn:uuid:zzz + correlation ID resolved dangling
Placeholders that match an entry fullUrl are rewritten to server-assigned literal IDs on atomic commit; a reference matching no entry, no server resource, and no conditional query is dangling and fails the whole transaction.

Implementation pattern

The pattern has two halves: a builder that produces a syntactically correct transaction Bundle using urn:uuid: placeholders (and a conditional-reference variant), and a validator that walks every reference in the bundle and asserts it resolves — so you flag unresolved edges before the POST. The builder mirrors the linkage in the diagram: one Patient entry, one Observation entry whose subject points at the Patient’s placeholder.

"""Build a FHIR R4 transaction Bundle with intra-bundle references and validate
the reference graph before POSTing. FHIR version 4.0.1."""
import re
import uuid
from typing import Dict, List, Set

# Match a search-style conditional reference: "ResourceType?param=value..."
CONDITIONAL_RE = re.compile(r"^[A-Z][A-Za-z]+\?.+")
# Match an absolute reference (already resolvable, not our job to mint).
ABSOLUTE_RE = re.compile(r"^https?://")


def new_urn() -> str:
    """Return a fresh urn:uuid placeholder for one bundle entry."""
    return f"urn:uuid:{uuid.uuid4()}"


def build_transaction(patient_ref: str = None) -> Dict:
    """Build a Patient + Observation transaction Bundle.

    If patient_ref is None, the Patient is created in-bundle and the
    Observation.subject points at its urn:uuid placeholder. If patient_ref
    is a conditional query (e.g. 'Patient?identifier=...'), the Observation
    references the Patient by search instead of a placeholder create.
    """
    entries: List[Dict] = []

    if patient_ref is None:
        patient_urn = new_urn()
        entries.append({
            "fullUrl": patient_urn,
            "resource": {
                "resourceType": "Patient",
                "identifier": [{"system": "urn:oid:2.16.840.1.113883.19.5",
                                "value": "MRN-998"}],
            },
            # ifNoneExist makes the create idempotent: skip if MRN already exists.
            "request": {"method": "POST", "url": "Patient",
                        "ifNoneExist": "identifier=urn:oid:2.16.840.1.113883.19.5|MRN-998"},
        })
        subject_reference = patient_urn
    else:
        # Conditional reference: the server resolves the Patient at commit time.
        subject_reference = patient_ref

    obs_urn = new_urn()
    entries.append({
        "fullUrl": obs_urn,
        "resource": {
            "resourceType": "Observation",
            "status": "final",
            "code": {"coding": [{"system": "http://loinc.org",
                                 "code": "8867-4", "display": "Heart rate"}]},
            "subject": {"reference": subject_reference},  # -> Patient placeholder or query
            "valueQuantity": {"value": 72, "unit": "beats/minute"},
        },
        "request": {"method": "POST", "url": "Observation"},
    })

    return {"resourceType": "Bundle", "type": "transaction", "entry": entries}

The ifNoneExist element on the Patient’s request is a conditional create: the server only creates the Patient if no resource matches the query, otherwise it links to the existing one — the idempotent complement to the conditional reference in the patient_ref branch. Either way the Observation.subject ends up pointing at a real Patient after commit.

Now the validator. It builds the set of declared fullUrl values, then walks every reference string in the bundle and classifies each: intra-bundle placeholder (must match a fullUrl), conditional, absolute, or literal. Only unresolved placeholders are hard failures raised before the POST.

def collect_references(node, refs: List[str]) -> None:
    """Recursively collect every Reference.reference string in a resource tree."""
    if isinstance(node, dict):
        if "reference" in node and isinstance(node["reference"], str):
            refs.append(node["reference"])
        for value in node.values():
            collect_references(value, refs)
    elif isinstance(node, list):
        for item in node:
            collect_references(item, refs)


def validate_reference_graph(bundle: Dict) -> List[str]:
    """Return a list of dangling references. Empty list == safe to POST.

    A reference is resolvable if it is: a urn:uuid matching an entry fullUrl,
    a conditional query, an absolute URL, or a relative literal (ResourceType/id)
    assumed to exist server-side. Anything else is dangling.
    """
    full_urls: Set[str] = {
        e["fullUrl"] for e in bundle.get("entry", []) if e.get("fullUrl")
    }
    dangling: List[str] = []

    for entry in bundle.get("entry", []):
        refs: List[str] = []
        collect_references(entry.get("resource", {}), refs)
        for ref in refs:
            if ref.startswith("urn:uuid:"):
                if ref not in full_urls:          # placeholder with no matching entry
                    dangling.append(ref)
            elif CONDITIONAL_RE.match(ref):        # Patient?identifier=... resolves at commit
                continue
            elif ABSOLUTE_RE.match(ref):           # absolute literal, resolvable externally
                continue
            elif "/" in ref:                        # relative literal ResourceType/id
                continue
            else:
                dangling.append(ref)                # unrecognized / broken form

    return dangling


if __name__ == "__main__":
    bundle = build_transaction()                    # in-bundle Patient create
    problems = validate_reference_graph(bundle)
    assert not problems, f"dangling references, do not POST: {problems}"
    # Conditional variant: reference an existing Patient by MRN search.
    cond = build_transaction("Patient?identifier=urn:oid:2.16.840.1.113883.19.5|MRN-998")
    assert not validate_reference_graph(cond)
    print("reference graph clean")

The validator deliberately treats conditional and literal references as resolvable-by-contract — it cannot see the server’s data, so it only catches the class it can prove wrong locally: a urn:uuid: placeholder that no entry in the bundle declares. That is the dangling case the diagram routes to the dead-letter queue, and the one that would otherwise fail the whole transaction on the server. For parsing inbound bundles into these structures, see how to parse FHIR JSON bundles in Python.

Validation and testing

Two assertions carry the correctness argument: the reference graph has no dangling edges, and the placeholder-to-id mapping is applied consistently once the server responds. Test the first against the bundle you are about to send; test the second against the transaction-response Bundle the server returns, where each entry carries response.location with the assigned literal ID.

import pytest


def test_clean_bundle_has_no_dangling_refs():
    """A well-formed in-bundle create must produce zero dangling edges."""
    assert validate_reference_graph(build_transaction()) == []


def test_dangling_placeholder_is_flagged():
    """An Observation.subject pointing at an undeclared urn:uuid is caught."""
    bundle = build_transaction()
    bundle["entry"][-1]["resource"]["subject"]["reference"] = f"urn:uuid:{uuid.uuid4()}"
    dangling = validate_reference_graph(bundle)
    assert len(dangling) == 1


def test_placeholder_mapping_applied_consistently():
    """Simulate a transaction-response: every placeholder maps to exactly one
    literal ID, and no rewritten reference still contains a urn:uuid."""
    placeholder_to_id = {
        "urn:uuid:aaa": "Patient/501",
        "urn:uuid:bbb": "Observation/902",
    }
    rewritten_subject = placeholder_to_id["urn:uuid:aaa"]
    assert rewritten_subject == "Patient/501"
    # No committed reference may still carry an unresolved placeholder.
    assert not rewritten_subject.startswith("urn:uuid:")
    # The mapping is a function: one placeholder -> one id, no collisions.
    assert len(set(placeholder_to_id.values())) == len(placeholder_to_id)

Run pytest -q in CI on every bundle template you ship. The test_dangling_placeholder_is_flagged case is the regression guard that matters most: it proves your pre-POST gate rejects the exact payload that would otherwise trigger an atomic rollback in production. For a real server round-trip, POST to the transaction endpoint and assert the response Bundle’s type is transaction-response and every entry’s response.status starts with 20.

Gotchas & compliance constraints

  • One unresolved reference fails the whole transaction. A transaction Bundle is atomic by spec: if any single reference cannot resolve, the server rolls back every write, so a dangling Observation.subject also discards the perfectly valid Patient beside it. This is a feature — it prevents orphaned clinical data — but it means a bad reference is never a partial success. Gate the reference graph before POST rather than reacting to a 400.
  • Circular references and conditional-reference ambiguity break resolution. Two entries whose placeholders reference each other cannot both be assigned first, and most servers reject the cycle; keep the reference direction acyclic (child points at parent). A conditional reference that matches zero resources errors (or triggers an unintended create), and one matching more than one resource is ambiguous and rejected with 412 Precondition Failed — so scope conditional queries to a truly unique key like a namespaced MRN, never a name or a date.
  • Partial writes are a clinical-integrity hazard, so audit the transaction as one unit. Because the transaction commits or rolls back atomically, your audit trail must record it as a single event keyed by one correlation ID — not per-entry. Logging a Patient create and its Observation create as independent events invites a reconciliation that “sees” an orphan that never actually persisted. Persist the transaction-response Bundle (with assigned IDs) alongside the correlation ID under immutable retention; that pairing is what satisfies HIPAA Security Rule audit-control requirements for the write as a whole.
Failure Trigger Guard
Atomic rollback Any dangling reference in the bundle Validate the reference graph before POST
412 ambiguous Conditional reference matches >1 resource Query on a unique namespaced identifier
Unintended create Conditional reference matches 0 resources Pair with ifNoneExist or fail explicitly
Orphan in audit log Per-entry audit of an atomic write Audit the transaction as one correlated unit

Getting the reference graph right depends on understanding how resources nest and point at one another in the first place — the containment and reference rules in the FHIR resource hierarchy are the model this validation enforces at write time, within the broader FHIR & HL7 v2 standards architecture.