Validating JSON Schemas for IoT Temperature Payloads
In a validated cold chain, the structure of a telemetry payload is a data-integrity control, not a developer convenience. FDA 21 CFR Part 11 §11.10(a) requires that closed systems be validated to ensure the accuracy and reliability of electronic records, and EU GMP Annex 11 §4.2 requires that data captured by computerised systems be protected against the introduction of erroneous values. A JSON payload that arrives with a missing timestamp, a temperature encoded as a string, or an unbounded value silently corrupts every downstream calculation it touches — excursion scoring, audit trails, and batch disposition included. This guide shows how to enforce a strict JSON Schema gate at ingestion, report every violation in one pass, and prove the control to an inspector. It is the field-level enforcement layer for the broader schema validation pipeline this section describes.
Prerequisites
- Python 3.11 or newer — the example uses standard-library type hints and f-strings throughout.
- Validation library:
pip install "jsonschema>=4.21,<5". TheDraft202012Validatorand itsiter_errorsmethod are the core of this guide. - Schema dialect: JSON Schema Draft 2020-12. Pin the dialect in the schema’s
$schemakeyword so a future library upgrade cannot silently change validation semantics — a CSV (computerised system validation) requirement under §11.10(a). - Transport assumption: payloads reach this validator after terminating mutual TLS at a hardened edge node. The secure IoT gateway that fronts your brokers should authenticate every device before a payload is ever offered to this gate.
- Sink for rejects: a durable quarantine store or dead-letter queue with a defined retention window (72 hours is typical) so no rejected record is lost — silent dropping breaks the ALCOA+ Complete attribute.
- Access control: the schema file and the quarantine store are change-controlled artifacts; write access is restricted and version-controlled so the data contract itself is auditable.
Step-by-Step Implementation
Step 1 — Author a deterministic data contract
A production schema must reject ambiguity rather than coerce it away. The contract below enforces strict typing, bounded temperature ranges, ISO 8601 temporal formatting, and explicit calibration metadata. Setting additionalProperties to false stops vendor-specific firmware fields from drifting into the regulated data lake unchecked.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.internal/pharma-temp-payload/v1.0.json",
"title": "Pharma IoT Temperature Telemetry",
"type": "object",
"properties": {
"sensor_id": {
"type": "string",
"format": "uuid",
"description": "Unique device identifier mapped to a calibration certificate"
},
"temperature_c": {
"type": "number",
"minimum": -40.0,
"maximum": 60.0,
"description": "Measured temperature in Celsius; bounds reject sensor faults"
},
"recorded_at": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$",
"description": "ISO 8601 timestamp with explicit offset or Zulu indicator"
},
"unit": {
"type": "string",
"enum": ["C"],
"description": "Explicit unit declaration to prevent implicit conversion"
},
"calibration_valid": {
"type": "boolean",
"description": "True if the device calibration certificate is within its validity window"
},
"firmware_version": {
"type": "string",
"pattern": "^v\\d+\\.\\d+\\.\\d+$",
"description": "Semantic version for firmware traceability"
}
},
"required": [
"sensor_id",
"temperature_c",
"recorded_at",
"unit",
"calibration_valid",
"firmware_version"
],
"additionalProperties": false
}
Each constraint maps to an integrity obligation: the recorded_at pattern forbids naive timestamps that would later break time-series alignment, and the minimum/maximum bounds turn an impossible reading into a rejected record rather than a false excursion. Confirm the contract is itself a legal Draft 2020-12 document before trusting it:
python -c "import json; from jsonschema import Draft202012Validator; \
Draft202012Validator.check_schema(json.load(open('schemas/pharma_temp_payload_v1.json'))); \
print('schema OK')"
Step 2 — Compile the validator once at startup
Loading and checking the schema on every payload wastes CPU and, worse, lets a corrupted schema file fail open under load. Compile once at process start and fail loudly if the contract is invalid — an unvalidated system must not begin ingesting under §11.10(a).
import json
import logging
from typing import Any, Dict, List
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
logger = logging.getLogger("telemetry.validation")
# Load the data contract once; runtime I/O per payload is both slow and a
# point of silent failure for the §11.10(a) validated state.
SCHEMA_PATH = "schemas/pharma_temp_payload_v1.json"
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
TEMPERATURE_SCHEMA = json.load(f)
try:
# check_schema proves the contract is a legal Draft 2020-12 document.
# Refusing to start on a bad schema keeps the system from ingesting
# unvalidated records — the core §11.10(a) accuracy guarantee.
Draft202012Validator.check_schema(TEMPERATURE_SCHEMA)
VALIDATOR = Draft202012Validator(TEMPERATURE_SCHEMA)
except SchemaError:
logger.critical("Invalid temperature schema; refusing to start ingestion.")
raise
Verify the module imports cleanly in your container before wiring it into the message bus:
python -c "import telemetry.validation as v; print('validator compiled:', bool(v.VALIDATOR))"
Step 3 — Report every violation in a single pass
An auditor needs the complete list of why a record was rejected, not just the first failing field. iter_errors yields every violation in one pass, so a payload that is missing a timestamp and carries an out-of-range temperature is logged with both faults. Route the result, never raise — exceptions discard data, and discarded data breaks the ALCOA+ Complete attribute.
def validate_temperature_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Validate one payload and return a routing decision (valid | quarantine).
iter_errors yields every violation in a single pass, so the audit log
records each fault exactly once — §11.10(e) requires a complete,
contemporaneous trail of why a record was rejected, not just the first reason.
"""
errors: List[str] = []
for err in VALIDATOR.iter_errors(payload):
path = ".".join(str(p) for p in err.absolute_path) or "root"
errors.append(f"Field '{path}': {err.message}")
if not errors:
# Accepted records flow to the bus unchanged; never mutate a validated
# record — provenance is part of the ALCOA+ Original attribute.
return {"status": "valid", "payload": payload, "errors": []}
# Annex 11 §4.2: erroneous values must be kept out of the regulated store,
# but the rejection itself is a record and must be preserved, not dropped.
logger.warning(
"Schema validation failed for sensor %s: %s",
payload.get("sensor_id", "unknown"),
"; ".join(errors),
)
return {"status": "quarantine", "payload": payload, "errors": errors}
Lock the behaviour down with assertions before release; a regressed validator that accepts bad data is a silent compliance failure:
good = {"sensor_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "temperature_c": 4.1,
"recorded_at": "2026-02-19T08:30:00Z", "unit": "C",
"calibration_valid": True, "firmware_version": "v2.3.1"}
assert validate_temperature_payload(good)["status"] == "valid"
bad = {**good, "temperature_c": "4.1", "recorded_at": "2026-02-19T08:30:00"}
result = validate_temperature_payload(bad)
assert result["status"] == "quarantine"
assert len(result["errors"]) == 2 # both the string temp and naive timestamp are reported
Step 4 — Quarantine rejects and never coerce
In a GxP environment, explicit rejection preserves provenance; implicit type casting destroys it. Route invalid payloads to a dead-letter queue keyed by sensor_id and firmware_version so a systemic firmware regression is distinguishable from an isolated hardware fault. Aggregating rejects by firmware_version is what turns this gate into an early-warning signal feeding excursion detection and CAPA review.
import collections
# 72h retention gives QA time to triage before a reject ages out, satisfying
# the ALCOA+ Available expectation without unbounded storage growth.
DLQ_RETENTION_HOURS = 72
_firmware_failures: "collections.Counter[str]" = collections.Counter()
def route_payload(payload: Dict[str, Any]) -> str:
"""Send valid records onward; quarantine the rest with full diagnostics."""
result = validate_temperature_payload(payload)
if result["status"] == "valid":
return "bus"
fw = payload.get("firmware_version", "unknown")
_firmware_failures[fw] += 1
# Never cast: a string "4.1" stays rejected. Coercion would fabricate a
# value the device never reported, violating ALCOA+ Original/Attributable.
dead_letter_write(result, retention_hours=DLQ_RETENTION_HOURS)
# A firmware version exceeding the reject threshold is a fleet-wide signal,
# not a one-off — surface it for OTA rollback / CAPA, not a quiet log line.
if _firmware_failures[fw] > 100:
logger.error("Firmware %s exceeded reject threshold; flag for rollback review.", fw)
return "quarantine"
def dead_letter_write(result: Dict[str, Any], retention_hours: int) -> None:
"""Persist a rejected record with its violations for audit and triage."""
# Implemented against your durable store (e.g. an SQS DLQ or a Kafka topic
# with a TTL); the reject record is itself a §11.10(e) audit-trail entry.
logger.info("DLQ write (%dh retention): %s", retention_hours, result["errors"])
For high-throughput fleets, validate inside the consumer described in async batching strategies, keeping one Draft202012Validator instance per worker thread to avoid shared-state hazards. Confirm rejects actually land in the DLQ before declaring the control live:
# After replaying a known-bad payload through the consumer, the DLQ must be non-empty.
python -m telemetry.replay --fixture tests/fixtures/bad_payload.json --expect quarantine
Compliance Validation Checklist
Run this as part of computerised-system validation; every item is something an auditor can independently confirm.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Valid-looking readings rejected after a firmware update | Firmware now sends temperature_c as a string, or omits the timezone offset on recorded_at |
Treat as fleet drift: aggregate rejects by firmware_version, suspend the OTA rollout, and require the device to emit a JSON number and an offset-bearing ISO 8601 timestamp |
| Validator passes payloads it should reject | additionalProperties omitted, or the library auto-upgraded the dialect |
Set additionalProperties: false, pin jsonschema to a <5 range, and construct with Draft202012Validator explicitly |
| Only the first error appears in the audit log | Code calls validate() (raises on first error) instead of iterating |
Switch to iter_errors and serialise the full list, satisfying the §11.10(e) complete-reason requirement |
| Ingestion latency spikes under load | Schema reloaded or recompiled per payload | Compile one validator at startup; reuse it, and use one instance per worker thread for parallel consumers |
| Rejected records vanish | Exceptions raised and swallowed, or DLQ misconfigured | Route, never raise; assert the DLQ is non-empty after a known-bad replay and confirm the retention window |
Related
- Schema Validation Pipelines for Temperature Telemetry
- Optimizing MQTT QoS Levels for Pharmaceutical Telemetry
- Aligning Asynchronous Sensor Timestamps in Python
- How to Map 21 CFR Part 11 Requirements to MQTT Payloads
- Designing Secure IoT Gateways for Pharma Logistics
For architectural context, see Schema Validation Pipelines for Temperature Telemetry, part of the broader IoT Sensor Data Ingestion & Time-Series Synchronization section.