Clinical Data Quality and Validation Frameworks for Python ETL

A parser that returns a well-typed DataFrame has proven only that the bytes were syntactically legal — not that a heart rate of 420 bpm, a discharge timestamp that precedes admission, or a mg/dL value carrying the LOINC code for a body temperature is clinically coherent. Schema validation confirms shape; it says nothing about whether the data is true enough to persist. Within the clinical data parsing and transformation workflows architecture, this page treats data-quality validation as its own pipeline stage — an explicit gate that sits after parsing and type coercion, before persistence and analytics — and shows how to build that gate in Python with pandera, Great Expectations, and targeted inline assertions so that structural, semantic, and referential defects are caught, quarantined, and reported rather than silently warehoused.

Prerequisites & Context

The validation stage assumes upstream tiers have already terminated transport, parsed the wire format, and coerced fields into typed columns. It validates meaning, not syntax, so it needs clean inputs and reference data, not raw bytes. Before implementing the checks below, confirm you have:

  • A parsed, provenance-tagged frame — one row per observation or encounter, produced by a FHIR object model such as fhir.resources for Python ETL or an HL7 v2 segment parser — with message_id and source_system preserved on every row.
  • Explicit type coercion for clinical data types already applied, so numeric observation values are real numbers and timestamps are timezone-aware datetime objects, not strings. Data-quality checks presuppose correct types.
  • A Python 3.10+ environment with pandas (or polars), pandera for typed schema validation, and optionally great_expectations for suite-based validation with human-readable documentation.
  • Reference value sets reachable at validation time: the UCUM unit set for the observations you accept, and LOINC/SNOMED membership lookups (a local table or a FHIR terminology server for $validate-code).
  • A quarantine destination — a table, object-store prefix, or dead-letter queue — governed by the same encryption and access policy as the warehouse, because rejected clinical records still contain PHI.

If any of these is missing, resolve it first. A validation gate fed unversioned, uncoerced, or context-stripped data produces false rejections and false passes in equal measure, and both erode trust in the pipeline faster than having no gate at all.

Concept & Spec Detail: Quality Is a Gate, Not a Schema Annotation

The reason data quality deserves its own stage is that the defect classes it catches are orthogonal to the ones schema validation catches. A JSON Schema or a FHIR profile can assert that Observation.valueQuantity.value is a decimal and that code is present. It cannot assert that a systolic blood pressure of 12 is physiologically implausible, that a mg/dL unit is nonsensical for a temperature observation, or that the LOINC code on the row actually belongs to LOINC. Those are semantic and referential facts, and they require checks that reason about the values and their relationships, not just their types.

It is useful to name the four dimensions of clinical data quality explicitly, because a validation suite that covers only one or two of them will pass data that later breaks a quality measure or a decision-support rule.

Dimension Question it answers Concrete clinical checks
Completeness Are required elements present? Every vitals panel row has a non-null value and unit; a required OBX-5 is populated; encounter has admit_time; no all-null observation rows
Conformance Do values obey their domain? unit is a member of the allowed UCUM set for that observation; loinc_code matches the LOINC pattern and exists in the release; status is in the FHIR value set (final, amended, …)
Plausibility Is the value biologically possible? Adult heart rate within roughly 20–300 bpm; body temperature 25–45 °C; no future observation timestamps; no birth dates before 1900; sex-specific observations consistent with recorded sex
Temporal consistency Do events order correctly in time? admit_timedischarge_time; observation effective_time falls within the encounter window; medication administration follows the order; no negative length-of-stay

Conformance deserves a precise note because it is the dimension most often reduced to a regex. Membership in a value set is not a pattern match: a code can be perfectly LOINC-shaped (8867-4) and still not exist in the loaded LOINC release, or exist but be deprecated. The canonical LOINC system URI is http://loinc.org; UCUM is a grammar, not an enumeration, so in practice you validate units against a curated allowed set per observation type rather than against all of UCUM. Plausibility ranges must be sourced from clinical reference tables and scoped by population — an adult heart-rate range applied to a neonate row will reject correct data.

The four dimensions map cleanly onto a single pipeline gate. The parsed frame enters, a suite of expectations runs, and each row is routed to exactly one of two outcomes: it passes to the warehouse, or it fails and is quarantined together with a structured validation report that says which expectation failed and why. The report is what makes the gate operable — a boolean “batch failed” tells an on-call engineer nothing.

Clinical data-quality gate routing a parsed frame to warehouse or quarantine A parsed, type-coerced DataFrame of observations enters a validation stage where a pandera schema and a Great Expectations suite evaluate four quality dimensions — completeness, conformance, plausibility, and temporal consistency. Rows that satisfy every expectation pass to the analytics warehouse. Rows that fail any expectation are routed to a PHI-safe quarantine together with a structured validation report naming the failed check, column, and row index, and both paths emit metrics: pass rate, failure counts by dimension, and quarantine depth. Parsed frame typed, coerced 1 row / observation Validation stage — expectation suite Completeness required value + unit present Conformance UCUM unit · LOINC membership Plausibility physiologic range · valid dates Temporal consistency admit ≤ discharge validate (lazy) collect all failures pass Analytics warehouse clean, lineage-tagged rows fail Quarantine + validation report check · column · row PHI-safe, encrypted Metrics pass rate · fails by dimension
The data-quality stage is a routing gate: every parsed row leaves as either a warehouse write or a quarantined record with a structured report, and both paths feed quality metrics.

Where each framework fits

Three tools cover the space, and they are complementary rather than competing. pandera expresses a typed DataFrameSchema with per-column Check predicates and cross-field wide checks; it is the right home for row-level completeness, conformance, and plausibility rules that you want expressed as code and unit-tested. Great Expectations organizes named expectations into suites, produces persisted validation results and human-readable data docs, and is the right home for dataset-level assertions and for stakeholder-facing quality reporting. Lightweight inline assert statements belong at trust boundaries — a single invariant you want to fail fast on before an expensive step — not as a substitute for either framework.

Approach Strengths When to use
pandera Typed schema as Python code; per-column and cross-field Checks; lazy=True collects every failure with row indices; trivially unit-testable; low overhead Row-level completeness, conformance, plausibility, and temporal checks embedded in the transform; the default gate for a DataFrame pipeline
Great Expectations Named expectation suites; persisted validation results; auto-generated data docs; dataset-level and distributional expectations; profiling Dataset-level quality (null-rate thresholds, uniqueness, distribution drift); audit-facing reports; multi-team pipelines needing shared, documented expectations
Inline asserts Zero dependencies; fail-fast; reads inline with the logic it guards A single hard invariant at a trust boundary (frame non-empty, key column present) before an expensive or irreversible step; never for the full quality contract

The practical pattern in a Python ETL is pandera as the enforced gate on every batch, Great Expectations run on a schedule or per-load for dataset-level and drift expectations plus the human-readable docs, and a handful of inline asserts guarding the boundaries between them.

Implementation

The gate decomposes into four ordered steps: declare a typed schema with the four quality dimensions as checks, validate lazily so a batch surfaces all its defects at once, route failures to quarantine with a structured report, and emit metrics. Each step has a validation gate of its own.

Step 1 — Declare a pandera schema for the Observation frame

The schema is the executable specification of “acceptable.” Typed columns cover the shape, per-column Check predicates cover completeness, conformance, and plausibility, and a wide Check on the schema covers cross-field temporal consistency. Scope ranges and unit sets to the observation type you are validating.

import re
import pandera as pa
from pandera import Column, Check, DataFrameSchema

LOINC_URI = "http://loinc.org"
# LOINC codes: 1-5 digits, a hyphen, then a single check digit.
LOINC_RE = re.compile(r"^\d{1,5}-\d$")

# Curated UCUM units allowed for vital-sign observations (not all of UCUM).
VITALS_UCUM = {"/min", "mm[Hg]", "Cel", "kg", "cm", "%", "{beats}/min"}

# Physiologic plausibility bounds for adult vitals, keyed by LOINC code.
ADULT_RANGES = {
    "8867-4": (20.0, 300.0),    # Heart rate, /min
    "8480-6": (40.0, 300.0),    # Systolic BP, mm[Hg]
    "8462-4": (20.0, 200.0),    # Diastolic BP, mm[Hg]
    "8310-5": (25.0, 45.0),     # Body temperature, Cel
    "2708-6": (40.0, 100.0),    # Oxygen saturation, %
}


def within_physiologic_range(df) -> "pa.typing.Series[bool]":
    """Vectorized plausibility check keyed by LOINC code.

    A value outside its code's adult range fails; a code with no known
    range is passed through here and caught by the conformance check.
    """
    lo = df["loinc_code"].map(lambda c: ADULT_RANGES.get(c, (float("-inf"),
                                                            float("inf")))[0])
    hi = df["loinc_code"].map(lambda c: ADULT_RANGES.get(c, (float("-inf"),
                                                            float("inf")))[1])
    return (df["value"] >= lo) & (df["value"] <= hi)


observation_schema = DataFrameSchema(
    columns={
        # Completeness: identifiers and value must be present.
        "message_id": Column(str, nullable=False),
        "loinc_system": Column(str, Check.equal_to(LOINC_URI), nullable=False),
        # Conformance: LOINC pattern membership.
        "loinc_code": Column(
            str,
            Check.str_matches(LOINC_RE),
            nullable=False,
        ),
        # Completeness + conformance: value present and unit in UCUM set.
        "value": Column(float, nullable=False),
        "unit": Column(
            str,
            Check.isin(VITALS_UCUM),
            nullable=False,
        ),
        "status": Column(
            str,
            Check.isin({"final", "amended", "corrected", "preliminary"}),
            nullable=False,
        ),
        # Temporal columns are timezone-aware datetimes after coercion.
        "admit_time": Column("datetime64[ns, UTC]", nullable=False),
        "discharge_time": Column("datetime64[ns, UTC]", nullable=True),
        "effective_time": Column("datetime64[ns, UTC]", nullable=False),
    },
    checks=[
        # Plausibility: physiologic range per LOINC code.
        Check(within_physiologic_range,
              error="value outside adult physiologic range for LOINC code"),
        # Temporal consistency: admit precedes or equals discharge.
        Check(lambda df: df["discharge_time"].isna()
              | (df["admit_time"] <= df["discharge_time"]),
              error="admit_time after discharge_time"),
        # Temporal consistency: no observation from the future.
        Check(lambda df: df["effective_time"]
              <= pa.pandas_engine.pd.Timestamp.utcnow(),
              error="effective_time in the future"),
    ],
    strict=False,
    coerce=False,  # coercion already happened upstream; do not mask type bugs
)

Validation gate: unit-test the schema against a fixture frame containing one known-good row and one deliberate violation per dimension (missing value, non-UCUM unit, heart rate of 420, admit_time after discharge_time). Assert each violation is reported and the good row passes. A schema with no failing test is a schema you cannot trust.

Step 2 — Validate lazily to collect every failure

Calling validate with lazy=True is the difference between an operable gate and a frustrating one. Eager validation raises on the first bad cell; lazy validation evaluates every check against every row and raises a single SchemaErrors carrying a failure_cases frame. That frame — check name, column, failed value, and row index — is the raw material for the quarantine report.

import pandas as pd


def validate_batch(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
    """Split a parsed batch into (valid_rows, failure_cases).

    Uses lazy validation so a single pass surfaces every defect in the
    batch rather than aborting on the first bad row.
    """
    try:
        observation_schema.validate(df, lazy=True)
        return df, pd.DataFrame(columns=["schema_context", "column",
                                         "check", "failure_case", "index"])
    except pa.errors.SchemaErrors as exc:
        failures = exc.failure_cases  # tidy frame: one row per failed cell
        bad_indices = set(failures["index"].dropna().astype(int))
        valid = df.loc[~df.index.isin(bad_indices)].copy()
        return valid, failures

Validation gate: assert that for a batch with n distinct defective rows, len(valid) == len(df) - n and that failures names every expected check. Confirm a fully clean batch returns an empty failure_cases frame and loses no rows — an off-by-one here silently drops good clinical data.

Step 3 — Route failures to a PHI-safe quarantine with a structured report

Rejected rows do not vanish and they do not get a best guess. Each is written to quarantine with a structured report that pairs the offending row with the checks it failed, so a data steward can triage without re-running the pipeline. The report is keyed by message_id and row index, and it records the failure reason rather than a raw copy of clinical free text where avoidable.

from datetime import datetime, timezone


def build_quarantine_report(df: pd.DataFrame,
                            failures: pd.DataFrame) -> list[dict]:
    """Produce one structured report per quarantined row.

    Groups failure cases by row index and attaches the minimal provenance
    needed to triage without re-reading PHI from the payload body.
    """
    reports = []
    for idx, group in failures.groupby(failures["index"].astype("Int64")):
        if pd.isna(idx):
            continue
        row = df.loc[int(idx)]
        reports.append({
            "message_id": row["message_id"],
            "source_row": int(idx),
            "rejected_at": datetime.now(timezone.utc).isoformat(),
            "failed_checks": [
                {"dimension_check": c, "column": col}
                for c, col in zip(group["check"], group["column"])
            ],
            "loinc_code": row["loinc_code"],  # code is not PHI; safe to log
        })
    return reports


def route_batch(df: pd.DataFrame, quarantine_sink, warehouse_sink) -> dict:
    valid, failures = validate_batch(df)
    reports = build_quarantine_report(df, failures)
    if reports:
        bad_indices = [r["source_row"] for r in reports]
        quarantine_sink.write(df.loc[bad_indices], reports)  # encrypted store
    if not valid.empty:
        warehouse_sink.write(valid)
    return {"passed": len(valid), "quarantined": len(reports)}

Validation gate: assert that every quarantined row has a report and every report references a row that was quarantined — the two sets must be exactly equal. A row in quarantine with no report is un-triageable; a report with no row is a lineage leak.

Step 4 — Emit metrics for observability

The gate is only trustworthy if its behavior is visible. Emit the pass rate, failure counts broken out by dimension, and quarantine depth on every batch so a drift — a vendor feed that suddenly fails conformance en masse — trips an alert instead of quietly filling quarantine.

from collections import Counter


def emit_metrics(df: pd.DataFrame, failures: pd.DataFrame,
                statsd) -> dict:
    """Emit batch quality metrics; return the summary for logging."""
    total = len(df)
    failed_rows = failures["index"].dropna().nunique()
    by_check = Counter(failures["check"])
    statsd.gauge("dq.batch.total", total)
    statsd.gauge("dq.batch.pass_rate",
                 (total - failed_rows) / total if total else 1.0)
    for check_name, count in by_check.items():
        statsd.increment("dq.failures", count, tags=[f"check:{check_name}"])
    return {"total": total, "failed_rows": int(failed_rows),
            "by_check": dict(by_check)}

Validation gate: assert the emitted pass_rate equals (total - failed_rows) / total for a mixed batch and exactly 1.0 for a clean one, and that per-check counts sum to the total number of failure cases. Metrics that disagree with the routing outcome are worse than no metrics.

Edge Cases & Vendor Deviations

The four dimensions are clean in the abstract; real EHR feeds violate them in ways a golden dataset rarely covers. The cases below decide whether a defect is a true reject or a normalization the gate should absorb first.

Source / scenario Deviation Handling
Missing vs. null vitals A vital is absent (row not sent) vs. present with an explicit null/HL7 NullFlavor (OBX-11 = X, “not done”) Distinguish the two: absence is a completeness failure; an explicit “not done” is conformant and passes with a status flag, not a range check
Unit mismatch (lb vs kg) Weight arrives in [lb_av] where the schema expects kg; height in [in_i] vs cm Convert in the transform before validation using UCUM canonical factors; reject only genuinely unknown units, never merely non-canonical ones
Out-of-range but valid extremes A real heart rate of 260 in SVT, or a temperature of 41.5 °C in hyperthermia Treat plausibility bounds as reject-implausible, not reject-abnormal; set bounds at the edge of physiologic possibility and flag clinically-abnormal-but-valid separately for review
Epic OBX quirks Epic packs multiple results into repeating OBX groups and may send display text in the units component Split repeats to one row each before validation; parse the UCUM code from the correct component, treating display text as non-authoritative
Cerner (Oracle Health) OBX Emits numeric results as ST (string) with embedded comparators (>90, <0.1) Strip and preserve the comparator during coercion; validate the numeric part, and carry the comparator as a separate flag rather than failing the row
Timezone-shifted timestamps A local naive timestamp is interpreted as UTC, shifting an effective_time hours into the “future” and tripping temporal checks Normalize to timezone-aware UTC in coercion using the sending facility’s offset; never validate temporal order on naive timestamps
Deprecated LOINC codes A code is LOINC-shaped and once valid but retired in the loaded release Conformance check must test membership in the release, not just the pattern; route deprecated codes to review with a deprecated_code reason

The unifying principle is that the gate must not conflate needs normalization with is defective. A lb weight and a >90 string are correctable in the transform tier; only a value that cannot be made coherent belongs in quarantine. Pushing correctable cases into quarantine inflates the false-reject rate and trains stewards to ignore the queue — the same discipline that governs type coercion for clinical data types decides the shape before the gate ever sees the value. At batch scale, run the gate inside the async batch processing topology so validation of one partition never blocks ingestion of the next.

Compliance Note: Quarantine and Reports Are Both PHI

A rejected clinical record is still a clinical record. Under the HIPAA Security Rule, the quarantine store and every validation report inherit the same obligations as the warehouse — a common and serious gap is treating quarantine as “just errors” and provisioning it without encryption, access control, or a retention policy. The constraint specific to this stage is that the validation report itself must be PHI-safe: it exists to explain why a record was rejected, and that explanation must be reconstructable by an auditor without leaking identifiers into logs.

Concretely: log failure reasons using structural facts — check name, column, LOINC code, row index — not the offending clinical value or any free-text display string, because a rejected value can itself be PHI (a plausibility failure on a birth date leaks the birth date if you log it verbatim). Persist the quarantine payload in an encrypted, access-controlled store with the same retention rules as the warehouse, and keep the report (structural, PHI-safe) in a separately governed observability layer from the payload (PHI, restricted). Every rejection should be an append-only, reconstructable audit event: an auditor must be able to answer “why was this message not persisted?” from the report alone, and answer “what were its exact values?” only through the access-controlled payload store. That separation is what lets you expose quality metrics and data docs to a broad team while keeping the PHI they summarize locked down.

Troubleshooting

Why not just rely on FHIR profile validation or JSON Schema instead of a separate data-quality stage?

Because they answer different questions. Profile and schema validation confirm structure — that a field exists, has the right cardinality, and is the right type. They cannot tell you that a heart rate of 420 is implausible, that a unit is nonsensical for the observation, or that a discharge precedes an admission. Those are semantic and referential facts about values and their relationships. Keep schema validation at the parsing boundary and add data-quality validation as its own downstream gate; each catches defects the other cannot.

Should I use pandera or Great Expectations for clinical validation?

Use both, for different layers. pandera expresses row-level completeness, conformance, plausibility, and cross-field temporal checks as typed Python code that is easy to unit-test and cheap to run on every batch, so it is the enforced gate. Great Expectations organizes named expectations into suites, persists validation results, and auto-generates human-readable data docs, so it is the right tool for dataset-level assertions, distribution drift, and audit-facing reporting. Inline asserts are for a single hard invariant at a trust boundary, never for the full contract.

A clinically valid extreme value keeps getting rejected. How do I fix the range check?

Your plausibility bounds are set at the edge of “normal” rather than the edge of “physiologically possible.” A resting heart rate of 260 in supraventricular tachycardia and a temperature of 41.5 Celsius in hyperthermia are abnormal but real. Widen plausibility bounds to reject only the biologically impossible, and move the abnormal-but-valid signal into a separate review flag rather than a hard rejection. The gate should reject impossible data, not flag clinically interesting data.

Lazy validation reports hundreds of failures but I only see a summary exception. How do I get row-level detail?

Call validate with lazy set to True and catch the SchemaErrors exception, then read its failure_cases attribute. That is a tidy DataFrame with one row per failed cell carrying the check name, column, failed value, and the original row index. Group it by index to build a per-row quarantine report. Eager validation raises on the first bad cell and hides the rest, which is why a batch gate should always validate lazily.

Is it safe to log the value that failed a validation check?

No. The failing value can itself be PHI — a birth date that failed a plausibility check, or a free-text result that failed conformance. Log only structural facts: the check name, the column, the LOINC code, and the row index. Keep the actual rejected payload in the encrypted, access-controlled quarantine store, governed by the same retention and access policy as the warehouse, and keep the PHI-safe report in a separately governed observability layer so metrics and data docs never carry identifiers.