Using fhir.resources for Python ETL
Production-grade clinical pipelines demand deterministic parsing, strict schema validation, and auditable transformation logic. The fhir.resources package gives you a Pydantic v2-backed, strongly typed interface to the HL7 FHIR R4/R5 specification, so engineering teams can replace fragile dictionary manipulation with contract-driven transforms. Within the Clinical Data Parsing & Transformation Workflows pipeline, this page covers how to make fhir.resources the validation boundary of a Python extract-transform-load job: enforcing structural integrity at ingestion while preserving clinical semantics for downstream analytics, machine-learning feature stores, and regulatory reporting. The goal is not “parse some JSON” — it is to treat every resource instantiation as a typed contract whose failures are observable, recoverable, and compliant.
Prerequisites & Context
Before wiring fhir.resources into a pipeline, confirm the following are in place:
If you are reading raw Bundle payloads off the wire, pair this page with how to parse FHIR JSON bundles in Python for the bundle-walking patterns that feed the validators below.
Concept & Spec Detail: fhir.resources as a Typed Contract
fhir.resources generates one Pydantic model per FHIR resource type directly from the published StructureDefinitions. When you call Patient.model_validate(data), Pydantic enforces FHIR cardinality, required elements, primitive regex constraints, and choice-type ([x]) exclusivity at instantiation time. Unlike loose json.loads deserialization, it raises pydantic.ValidationError on any structural deviation — which is exactly the signal an idempotent pipeline needs to branch on.
The methods you will use most, and how they differ, are worth keeping straight:
| Operation | Pydantic v2 method | When to use it in ETL |
|---|---|---|
| Validate a dict/JSON into a model | Model.model_validate(data) |
Ingestion boundary — raises on structural violations |
| Validate raw JSON bytes | Model.model_validate_json(raw) |
Skips an intermediate json.loads; lower overhead on hot paths |
| Serialize back to a dict | instance.model_dump(exclude_unset=True) |
Re-emitting to a downstream FHIR sink |
| Serialize to JSON string | instance.model_dump_json(exclude_unset=True) |
Writing NDJSON shards for warehouse loaders |
| Inspect declared fields | Model.model_fields |
Driving column projection or schema-drift detection |
Two behaviours matter for correctness. First, exclude_unset=True preserves the distinction between “field absent” and “field explicitly null” — critical for clinical null semantics, where an absent lab value and a value that was asked but unknown must not collapse into the same output. Second, FHIR primitives (date, dateTime, decimal, code, uri) are strict: a partial date like 2023-05, a timezone-naive timestamp, or a locale-formatted decimal will be rejected unless normalized first. Treat fhir.resources as the contract, not the janitor — clean inputs in a stage that runs before validation.
Implementation
Step 1 — Validate at the ingestion boundary with DLQ fallback
Wrap every instantiation in explicit exception handling. Structural failures route to a DLQ with enough context to triage, but without persisting raw clinical payloads to unencrypted log targets. Use a stable cryptographic hash of the canonicalized payload as the correlation key — never Python’s built-in hash(), which is salted per-process and not reproducible across runs.
import json
import hashlib
import logging
from pydantic import ValidationError
from fhir.resources.patient import Patient
from fhir.resources.observation import Observation
logger = logging.getLogger("clinical.etl.validation")
# Dispatch table keyed by FHIR resourceType -> model class.
VALIDATORS = {
"Patient": Patient,
"Observation": Observation,
# Extend with additional resource types as the pipeline grows.
}
def ingest_fhir_bundle(payload: dict) -> list[dict]:
"""Validate and route FHIR resources from a Bundle, with DLQ fallback."""
valid_resources: list[dict] = []
for entry in payload.get("entry", []):
resource_data = entry.get("resource", {})
resource_type = resource_data.get("resourceType")
model = VALIDATORS.get(resource_type)
try:
if model is not None:
model.model_validate(resource_data) # raises on any FHIR violation
valid_resources.append(resource_data)
except ValidationError as exc:
dlq_record = {
"error": "validation_failure",
"resource_type": resource_type,
# Stable, reproducible correlation key — NOT Python's hash().
"payload_hash": hashlib.sha256(
json.dumps(resource_data, sort_keys=True).encode()
).hexdigest()[:16],
"details": str(exc),
}
logger.error("DLQ routed: %s", dlq_record)
# Push dlq_record to the encrypted DLQ topic in production.
return valid_resources
The DLQ record above deliberately omits the raw payload. Persisting PHI to a log sink is the single most common HIPAA violation in clinical ETL; carry the payload_hash instead and resolve the original from the encrypted quarantine store only when a human triages it.
Validation check:
# A malformed Patient (gender is a bound code; "M" is not a valid value) must be quarantined.
out = ingest_fhir_bundle({"entry": [{"resource": {"resourceType": "Patient", "gender": "M"}}]})
assert out == [], "invalid gender code should have routed to the DLQ, not passed through"
Step 2 — Normalize clinical primitives before validation
Real EHR exports routinely carry partial dates, timezone-naive timestamps, and over-precise decimals. Run a normalization pass first so strict FHIR primitives validate cleanly. The deeper conversion rules for each data type live in Type Coercion for Clinical Data Types; this stage applies the minimum needed to satisfy the contract.
import re
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
def normalize_fhir_primitives(data: dict) -> dict:
"""Pre-validate normalization for common clinical primitives."""
# Expand partial dates: "2023" -> "2023-01-01", "2023-05" -> "2023-05-01".
if "birthDate" in data and isinstance(data["birthDate"], str):
bd = data["birthDate"]
if re.fullmatch(r"\d{4}", bd):
data["birthDate"] = bd + "-01-01"
elif re.fullmatch(r"\d{4}-\d{2}", bd):
data["birthDate"] = bd + "-01"
# Label timezone-naive effectiveDateTime explicitly. If the source emits
# local time, use .astimezone() instead of assuming UTC.
if "effectiveDateTime" in data and isinstance(data["effectiveDateTime"], str):
dt_str = data["effectiveDateTime"]
if not dt_str.endswith("Z") and not re.search(r"[+-]\d{2}:?\d{2}$", dt_str):
dt = datetime.fromisoformat(dt_str).replace(tzinfo=timezone.utc)
data["effectiveDateTime"] = dt.isoformat()
# Enforce decimal precision for clinical measurements without losing the value.
if "valueQuantity" in data and "value" in data["valueQuantity"]:
try:
val = Decimal(str(data["valueQuantity"]["value"]))
# Stored as float for FHIR JSON; track significant figures separately.
data["valueQuantity"]["value"] = float(val.quantize(Decimal("0.001")))
except InvalidOperation:
raise ValueError("Non-numeric clinical measurement detected")
return data
Validation check:
assert normalize_fhir_primitives({"birthDate": "1980"})["birthDate"] == "1980-01-01"
Step 3 — Map legacy null semantics to data-absent-reason
FHIR does not carry the HL7 v2/v3 nullFlavor attribute natively. To preserve the difference between not asked, asked but unknown, and masked, map each flavor onto the FHIR data-absent-reason extension. Note the value type: the R4 extension definition specifies valueCode, not valueCodeableConcept.
# HL7 v2/v3 nullFlavor -> FHIR data-absent-reason codes.
NULL_FLAVOR_TO_DAR = {
"UNK": "unknown",
"ASKU": "asked-unknown",
"NASK": "not-asked",
"MSK": "masked",
}
DATA_ABSENT_REASON_URL = "http://hl7.org/fhir/StructureDefinition/data-absent-reason"
def apply_data_absent_reason(resource: dict, null_flavor: str) -> dict:
"""Attach a FHIR-compliant data-absent-reason extension for a legacy null."""
dar_code = NULL_FLAVOR_TO_DAR.get(null_flavor)
if not dar_code:
return resource # Unknown flavor: leave unchanged and log upstream.
resource.setdefault("extension", []).append({
"url": DATA_ABSENT_REASON_URL,
"valueCode": dar_code,
})
return resource
The full mapping matrix — including how to attach the extension at element level versus resource level — is detailed in Handling nullFlavor in FHIR resource extraction.
Step 4 — Stream and serialize at scale
fhir.resources models are memory-intensive when instantiated in bulk, so never load an entire export into a list. Process in generator-based chunks and serialize validated resources to NDJSON for downstream sinks.
from typing import Iterator, Generator
def chunked_fhir_processing(
source_iter: Iterator[dict],
chunk_size: int = 5000,
) -> Generator[list[dict], None, None]:
"""Stream FHIR resources in memory-safe chunks."""
buffer: list[dict] = []
for resource in source_iter:
buffer.append(resource)
if len(buffer) >= chunk_size:
yield buffer
buffer = []
if buffer:
yield buffer
def batch_validate_and_dump(chunk: list[dict]) -> list[str]:
"""Validate a chunk and serialize survivors to NDJSON lines."""
serialized: list[str] = []
for res in chunk:
try:
if res.get("resourceType") == "Patient":
validated = Patient.model_validate(res)
serialized.append(validated.model_dump_json(exclude_unset=True))
except ValidationError:
continue # Route to DLQ in production; skipped here for brevity.
return serialized
For the DataFrame-oriented path — PyArrow-backed schemas, memory-mapped I/O, and chunked model_dump() into columnar form — see Optimizing pandas for FHIR JSON parsing. For multi-worker pipelines that fan these chunks across a queue, the backpressure and idempotency patterns in async batch processing for large datasets apply directly.
Edge Cases & Vendor Deviations
Even well-formed exports from major EHRs carry quirks that fhir.resources will reject because it follows the base spec strictly. Handle these in the normalization stage, not by loosening validation.
| Source | Deviation | Effect on validation | Mitigation |
|---|---|---|---|
| Epic | Custom extensions on Patient and Observation outside US Core |
Accepted (FHIR allows unknown extensions) but downstream consumers may choke | Validate against US Core profiles after base validation |
| Cerner (Oracle Health) | Partial effectiveDateTime and timezone-naive timestamps in observations |
ValidationError on the dateTime primitive |
Run Step 2 normalization; never assume UTC for local-time sources |
| athenahealth | Local/proprietary codes in code.coding without a system URI |
Passes structural validation but fails terminology binding | Resolve via a FHIR terminology server before load |
| Mixed R4/R5 feeds | Observation.value[x] shape and choice-type differences across versions |
Wrong model version raises on otherwise valid data | Pin the fhir.resources major version to your target FHIR release |
| Any bulk export | UTF-8 BOM or non-deterministic key ordering | Unstable payload hashes and duplicate DLQ entries | Canonicalize (strip BOM, sort_keys=True) before hashing |
A frequent trap is “fixing” a ValidationError by switching to a generic untyped resource. That discards the contract entirely. Prefer narrowing the deviation in normalization, or validate against a profile-relaxed model only for the specific element at fault.
Compliance Note
fhir.resources enforces structural compliance; it does nothing for the HIPAA controls a pipeline still owes. Three obligations attach specifically to this validation stage:
- PHI must never reach an unencrypted sink. As shown in Step 1, DLQ and log records carry a
payload_hashand error metadata only. The raw resource lives solely in the encrypted quarantine store, and access to it is itself an auditable event under the HIPAA Security Rule’s minimum-necessary principle. - Deterministic identifiers preserve auditability. Replace vendor-assigned IDs with a SHA-256 hash of a composite key (for example
patient_id + encounter_date + resourceType) so the same clinical fact produces the same surrogate across staging and production — a requirement for 21 CFR Part 11 lineage and for idempotent joins. - Field-level redaction belongs in serialization. Apply masking with a Pydantic
@field_serializeror middleware onmodel_dump()so PHI is removed at the moment of output, not bolted on afterward where a missed path can leak data.
Emit structured validation metrics (success/failure rate, DLQ volume, schema-drift alerts) to your observability platform so that a sudden spike in rejected resources — often a sign of an upstream EHR upgrade — is caught before it silently degrades the dataset.
Troubleshooting
Every resource fails with "model has no attribute model_validate".
You are on fhir.resources v6 or earlier, which is Pydantic v1 and uses parse_obj / .dict() / .json(). The v2 API (model_validate, model_dump, model_dump_json) requires fhir.resources>=7 and pydantic>=2. Pin both in your lockfile; mixing a v1 library with a v2 Pydantic, or vice versa, produces import-time and attribute errors.
Valid-looking dates and timestamps are rejected by Pydantic.
FHIR primitives are strict. Partial dates (2023-05), timezone-naive dateTime values, and locale-formatted decimals all violate the primitive regex. Run the Step 2 normalization pass before model_validate. Do not relax the model — the strictness is what keeps downstream consumers safe.
My DLQ fills with the same payload retried endlessly.
A ValidationError is a terminal error: it will fail identically on every retry. Catch it separately from transient failures (timeouts, 5xx, lock contention) and route it straight to quarantine, reserving the retry loop for errors that can actually succeed later. The Step 1 handler shows this split.
Idempotency keys change between identical resends.
Byte-level differences — a UTF-8 BOM, key ordering, or transient meta tags — change the hash even when the clinical content is identical. Canonicalize before hashing: strip non-deterministic metadata, serialize with sort_keys=True, and normalize to UTF-8. Use hashlib.sha256, never Python’s salted built-in hash().
The pipeline OOMs on a large bulk export.
You are materializing the whole export into a list before validating. Stream it through chunked_fhir_processing (Step 4) and validate one chunk at a time. For DataFrame conversion, switch to the PyArrow-backed, chunked approach in the pandas optimization guide rather than building a single in-memory frame.