Type Coercion for Clinical Data Types
Type coercion is the transformation step where a string like 98.6, a partial date like 2015-03, or a coded value like 2345-7^Glucose^LN becomes a typed, canonical value your warehouse and decision-support engines can trust. In clinical data engineering it is not a syntactic convenience — it is a deterministic, auditable discipline. Implicit casts and ad-hoc parsing introduce silent corruption: a truncated lab decimal becomes a wrong reference-range flag, a dropped timezone offset inverts a medication-administration timeline, and an invalid string coerced to zero becomes a clinically dangerous result. Within the broader Clinical Data Parsing & Transformation Workflows pipeline, coercion sits at the boundary between raw ingestion and canonical normalization — after schema validation, before the typed values are projected into the target store. This page covers the concrete engineering of that boundary: a versioned coercion registry, precision-preserving numeric handling, strict temporal normalization, explicit null semantics, and the immutable audit trail that keeps the whole operation defensible under regulatory scrutiny.
The reader who needs this page is usually debugging a specific failure — a FLOAT64 rounding artifact in a dosage column, a partial date that silently became midnight UTC, or a deprecated code that slipped into an analytical table. The patterns below are written to be lifted into a production Python pipeline and tested in isolation.
Prerequisites & Context
These items are load-bearing for the implementation that follows. Confirm each before wiring coercion into your pipeline.
Coercion runs after structural validation. Invalid payloads should never reach the coercion stage; they bypass it and route to error handling, exactly as in the async batch worker control flow.
Concept & Spec Detail: Deterministic Coercion Rules
A correct coercion layer is defined by three non-negotiable properties, then a rule table that maps every source representation to a canonical typed output.
1. Idempotency. Re-running coercion against the same raw payload must yield byte-identical canonical output. This requires stateless transformation functions, a versioned rule registry, and deterministic fallback hierarchies. Ambiguous states map to explicit semantic values rather than being collapsed.
2. Precision preservation. Clinical decimals (lab results, dosages, vitals) and temporal types cannot tolerate IEEE-754 floating-point drift or implicit timezone resolution. Coercion uses fixed-precision arithmetic (decimal.Decimal) and strict ISO 8601 parsing with explicit offset retention.
3. Explicit failure modes. Coercion never silently truncates, coerces an invalid string to zero, or guesses a missing unit. Fail-fast validation routes malformed payloads to quarantine, logged with a cryptographic hash of the source.
Coercion rules by clinical type
The registry is the single source of truth for how each source type becomes a canonical value. Treat this table as the contract the implementation must satisfy.
| Clinical source type | Example raw value | Canonical target | Coercion rule | Failure handling |
|---|---|---|---|---|
Numeric lab result (HL7 NM, FHIR decimal) |
98.5, 1,250 |
Decimal with fixed scale |
Strip grouping separators; Decimal(str(v)).quantize(scale); reject scientific notation |
Quarantine on InvalidOperation |
Quantity with unit (FHIR Quantity) |
5.0 mg/dL |
Decimal value + UCUM code |
Coerce value; normalize unit to UCUM; apply verified conversion factor | Quarantine if unit unmapped |
Instant / dateTime (FHIR dateTime, HL7 TS) |
2024-01-15T08:30:00-05:00 |
UTC datetime (offset-aware) |
datetime.fromisoformat; .astimezone(utc); reject naive |
Quarantine naive/invalid |
Partial date (FHIR date) |
2015-03, 2020 |
period.start/period.end range |
Map to month/year boundary range; never assume midnight | Flag for clinical review |
Coded concept (HL7 CE/CWE, FHIR Coding) |
2345-7^Glucose^LN |
Validated `system | code` | Validate against active value set; crosswalk deprecated codes |
| Boolean / status flag | Y, 1, true, A |
bool or canonical enum |
Map via explicit registry; never truthiness coercion | Quarantine unrecognized token |
| Missing / null sentinel | "", NA, UNK, null |
NULL / UNKNOWN / NOT_APPLICABLE |
Map to distinct canonical null states | n/a (explicit) |
The last row is where most pipelines quietly fail. Regulatory frameworks distinguish NULL (data not collected) from UNKNOWN (collected but unverified) and NOT_APPLICABLE (clinically irrelevant). A registry that collapses all three into a single SQL NULL destroys information an auditor or analyst depends on.
Implementation
The following steps build a deterministic coercion layer incrementally. Each step is independently testable.
Step 1: A versioned coercion registry
Centralize coercion logic behind a registry keyed by clinical type. Versioning the registry is what makes coercion auditable: every transformed field records which rule version produced it.
from decimal import Decimal, InvalidOperation
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Callable, Any
REGISTRY_VERSION = "2024.1.0"
@dataclass
class CoercionResult:
status: str # "coerced" | "explicit_null" | "quarantined"
value: Any = None
canonical_null: str | None = None # NULL | UNKNOWN | NOT_APPLICABLE
rule_version: str = REGISTRY_VERSION
error: str | None = None
NULL_SENTINELS = {
"": "NULL", "NA": "NULL", "NULL": "NULL",
"UNK": "UNKNOWN", "ASKU": "UNKNOWN",
"NAV": "NOT_APPLICABLE", "NASK": "NOT_APPLICABLE",
}
def classify_null(raw: str) -> str | None:
"""Map a source sentinel to a canonical null state, or None if it is real data."""
return NULL_SENTINELS.get(raw.strip().upper())
Validation — the null classifier must keep the three states distinct:
assert classify_null("UNK") == "UNKNOWN"
assert classify_null("") == "NULL"
assert classify_null("NASK") == "NOT_APPLICABLE"
assert classify_null("98.5") is None
Step 2: Precision-preserving numeric coercion
Numeric coercion is where FLOAT64 drift originates. Use Decimal, quantize to a declared scale, and strip locale grouping separators before parsing.
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
def coerce_numeric(raw: str, scale: str = "0.01") -> CoercionResult:
"""Coerce a clinical numeric string to a fixed-scale Decimal."""
null_state = classify_null(raw)
if null_state is not None:
return CoercionResult(status="explicit_null", canonical_null=null_state)
try:
cleaned = raw.strip().replace(",", "") # strip grouping separators
if "e" in cleaned.lower():
raise InvalidOperation("scientific notation rejected")
value = Decimal(cleaned).quantize(Decimal(scale), rounding=ROUND_HALF_UP)
return CoercionResult(status="coerced", value=value)
except InvalidOperation as exc:
return CoercionResult(status="quarantined", error=f"non-numeric: {exc}")
Validation — assert no precision loss and explicit rejection of bad input:
assert coerce_numeric("1,250.5", "0.1").value == Decimal("1250.5")
assert coerce_numeric("UNK").canonical_null == "UNKNOWN"
assert coerce_numeric("abc").status == "quarantined"
assert coerce_numeric("1.005", "0.01").value == Decimal("1.01") # ROUND_HALF_UP
Step 3: Strict temporal normalization
Temporal coercion must retain the source offset and reject naive datetimes. Partial dates are not errors — they are mapped to a period range rather than an implied midnight. The full set of offset-drift cases is covered in debugging timezone mismatches in clinical timestamps.
import re
PARTIAL_DATE = re.compile(r"^\d{4}(-\d{2})?$") # "2020" or "2020-03"
def coerce_temporal(raw: str) -> CoercionResult:
"""Coerce a clinical timestamp to an offset-aware UTC datetime, or a period range."""
null_state = classify_null(raw)
if null_state is not None:
return CoercionResult(status="explicit_null", canonical_null=null_state)
raw = raw.strip()
if PARTIAL_DATE.match(raw):
# Map a partial date to an explicit range; never assume midnight.
return CoercionResult(status="coerced", value={"partial": raw})
try:
parsed = datetime.fromisoformat(raw)
if parsed.tzinfo is None:
return CoercionResult(status="quarantined",
error="naive datetime rejected (no offset)")
return CoercionResult(status="coerced", value=parsed.astimezone(timezone.utc))
except ValueError as exc:
return CoercionResult(status="quarantined", error=f"invalid timestamp: {exc}")
Validation:
r = coerce_temporal("2024-01-15T08:30:00-05:00")
assert r.value.isoformat() == "2024-01-15T13:30:00+00:00" # offset preserved → UTC
assert coerce_temporal("2015-03").value == {"partial": "2015-03"}
assert coerce_temporal("2024-01-15T08:30:00").status == "quarantined" # naive rejected
Step 4: FHIR resource coercion
When parsing FHIR resources, a validated object model prevents silent schema drift. Coerce valueQuantity and effectiveDateTime through the registry rather than reading raw JSON.
from fhir.resources.observation import Observation
from pydantic import ValidationError
def coerce_observation(obs_json: dict) -> dict:
"""Deterministic coercion of a FHIR Observation's value and effective time."""
try:
obs = Observation.model_validate(obs_json) # structural validation first
except ValidationError as exc:
return {"status": "quarantined", "error": str(exc)}
if not obs.valueQuantity:
return {"status": "explicit_null", "canonical_null": "NULL"}
num = coerce_numeric(str(obs.valueQuantity.value), scale="0.0001")
if num.status == "quarantined":
return {"status": "quarantined", "error": num.error}
effective = None
if obs.effectiveDateTime:
t = coerce_temporal(str(obs.effectiveDateTime))
if t.status == "quarantined":
return {"status": "quarantined", "error": t.error}
effective = t.value.isoformat() if hasattr(t.value, "isoformat") else t.value
return {
"status": num.status,
"value": float(num.value) if num.value is not None else None,
"unit": obs.valueQuantity.unit, # normalize to UCUM downstream
"effective_utc": effective,
"rule_version": REGISTRY_VERSION,
}
Step 5: HL7 v2 OBX coercion
HL7 v2 relies on delimited strings and implicit typing. Extract the OBX-5 value via the library, then route it through the same numeric coercion so FHIR and v2 paths share one rule set. For the segment-to-resource mapping, see converting HL7 v2 OBX segments to FHIR Observation.
from hl7apy.parser import parse_message
def coerce_obx_numeric(raw_obx_segment: str) -> CoercionResult:
"""Parse an HL7 v2 OBX segment and coerce OBX-5 to a fixed-scale Decimal.
Expects one OBX line, e.g.:
'OBX|1|NM|2345-7^Glucose^LN||98.5|mg/dL|...'
"""
wrapper = (
"MSH|^~\\&|LAB|FAC|RCV|FAC|20240101120000||ORU^R01|1|P|2.5\r"
"OBR|1\r"
f"{raw_obx_segment}\r"
)
try:
msg = parse_message(wrapper, find_groups=False)
obx_list = [c for c in msg.children if c.name == "OBX"]
if not obx_list:
return CoercionResult(status="quarantined", error="no OBX segment")
raw_value = obx_list[0].obx_5.value.strip() if obx_list[0].obx_5 else ""
except Exception as exc: # hl7apy raises a range of parse exceptions
return CoercionResult(status="quarantined", error=f"parse failure: {exc}")
return coerce_numeric(raw_value, scale="0.01")
Validation:
ok = coerce_obx_numeric("OBX|1|NM|2345-7^Glucose^LN||98.5|mg/dL|")
assert ok.status == "coerced" and ok.value == Decimal("98.50")
bad = coerce_obx_numeric("OBX|1|NM|2345-7^Glucose^LN||positive|mg/dL|")
assert bad.status == "quarantined"
Edge Cases & Vendor Deviations
Clinical data rarely conforms to textbook schemas. Handle these explicitly rather than letting them surface as silent data loss.
| Source / quirk | Symptom in coercion | Mitigation |
|---|---|---|
| Epic | effectiveDateTime emitted without an offset on some flowsheet rows |
Reject naive datetimes; require the timezone to be resolved from MSH-7 / facility config before coercion, not after |
| Cerner (Oracle Health) | Numeric OBX-5 with embedded comparators (<5, >120) |
Split comparator into a separate Quantity.comparator; coerce only the numeric remainder |
| athenahealth | Locale grouping separators (1,250) in lab values |
Strip separators before Decimal; never float() the raw string |
| Legacy v2 interfaces | Local null codes (9999999, -1) used as sentinels |
Extend the null registry per source system; map to canonical NULL/UNKNOWN, do not coerce to a real number |
| Mixed unit systems | Same analyte in mg/dL and mmol/L across sites |
Apply verified UCUM conversion factors; never hardcode a multiplier inline |
| Deprecated codes | Retired CE/CWE or Coding values |
Validate against active value sets and crosswalk via SNOMED CT to ICD-10 mapping strategies and mapping LOINC codes to clinical lab results |
Downstream FLOAT64 columns |
Quantized Decimal re-introduces drift on load |
Cast to NUMERIC(p,s) / DECIMAL with declared scale before the warehouse write |
The comparator case deserves emphasis: coercing <5 by stripping the < and storing 5 is a clinically dangerous data loss. The comparator carries meaning (the true value is below the detection limit) and must be preserved in a dedicated field.
Compliance Note: Provenance for Coerced Values
Coercion is a transformation of a clinical record, so under the HIPAA Security Rule, 21 CFR Part 11, and GDPR data-minimization it must be reconstructable. The audit requirement that applies specifically to this stage is lineage: every coerced field must let an auditor reconstruct the exact path from raw payload to analytical column.
- Immutable provenance logging. For each coercion event, append (never overwrite)
source_payload_hash,rule_version,input_type,output_type, andcoercion_timestamp. Therule_versionfrom the registry is what lets you replay historical coercions exactly. - Hash the source, not the PHI. Log a SHA-256 hash of the raw value for traceability; never write the raw clinical value or patient identifier into the coercion log.
- Preserve null semantics end to end. Carry the canonical null state (
NULL/UNKNOWN/NOT_APPLICABLE) through to the target schema. Collapsing them at load time is both an analytical defect and a compliance gap, because “not collected” and “patient declined” are legally distinct. - Quarantine without leaking. A payload that fails coercion still contains PHI. Route it to an encrypted DLQ with a hashed reference, exactly as the async batch worker does — never inline the raw value in the error record.
Troubleshooting
Lab values look correct in staging but drift by tiny amounts in the warehouse.
You quantized with Decimal but the target column is FLOAT64, which re-introduces binary floating-point drift on load. Declare the warehouse column as NUMERIC(p,s) / DECIMAL with the same scale you quantized to, and confirm your loader (pandas, the BigQuery client, etc.) is not silently casting Decimal back to float on the way in.
Timestamps shifted by several hours after coercion.
A naive datetime was coerced and an implicit timezone was applied. Reject naive datetimes at the boundary (as coerce_temporal does) and resolve the source offset explicitly — from the FHIR dateTime offset, or from MSH-7 / facility configuration for HL7 v2 — before converting to UTC. See debugging timezone mismatches in clinical timestamps for the full set of offset-drift cases.
Re-running the pipeline produces different idempotency keys for the same record.
Coercion is not deterministic somewhere in the path. The usual culprits are float() parsing (non-reproducible representation), unstable rounding (no fixed ROUND_HALF_UP), or locale-dependent separator handling. Route every numeric through one quantizing function and hash the canonical output, not the raw string.
Analysts complain that "missing" and "unknown" values are indistinguishable.
Your registry is collapsing distinct null sentinels into a single SQL NULL. Map ""/NA to NULL, UNK/ASKU to UNKNOWN, and NAV/NASK to NOT_APPLICABLE, and carry that canonical state into a dedicated column. The classify_null map in Step 1 keeps the three states separate.
A retired diagnosis code passed coercion and reached an analytical table.
Coded values were coerced structurally but never validated semantically. Validate every Coding / CE / CWE against an active value set on a FHIR terminology server and crosswalk deprecated codes to current equivalents before projection — do not trust the source system’s code as current.