Validating Sensor Payloads with Pydantic
A payload validator’s job is narrower than it first appears: it decides whether a message is
structurally a reading, not whether the reading is good. Get that boundary wrong in either
direction and the pipeline suffers — too permissive and a string "NaN" propagates into a
database column, too strict and the validator silently deletes the extreme values that
environmental monitoring exists to capture. This page builds a Pydantic v2 model that draws the
line correctly, coerces units at the boundary, and routes every rejection to a dead-letter record
that says why. It sits immediately before the registry join described in
sensor metadata and device registry
management.
What Belongs in the Schema and What Does Not
Three questions separate a schema concern from a quality concern.
Can the pipeline represent this value at all? A latitude of 412, a timestamp of "soon", a
measurement of null — these cannot be stored in a typed column, so they are schema failures.
Is the value physically possible for this instrument? A relative humidity of 140% cannot exist; a PM2.5 of 900 µg/m³ can, during a severe wildfire. The first is a schema failure because no valid instrument state produces it. The second is a quality question.
Is the value plausible given context? A temperature 8 °C above every neighbouring sensor is suspicious but perfectly representable. This is never a schema concern — it belongs to anomaly detection, which has the context to judge it and the flag vocabulary to record its judgement.
The practical rule: the validator’s bounds should be the instrument’s physical limits from its datasheet, widened slightly, not the range you expect to see. A sensor that can only report −40 to +85 °C should reject 200 °C, because that value means a decode error rather than a hot day.
Production-Ready Implementation
# python 3.11 · pydantic==2.7.1
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
MAX_FUTURE = timedelta(minutes=5) # allow small clock skew, reject time travel
MAX_AGE = timedelta(days=30) # older than this is a replay, not telemetry
class SensorReading(BaseModel):
"""One decoded environmental reading, at the ingest boundary.
Bounds are the *instrument's* physical limits, not the expected range: the
job here is to reject values that no working sensor can produce, and to let
every extreme-but-possible value through to QC flagging with its flag intact.
"""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
device_id: Annotated[str, Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9:_-]+$")]
observed_at: datetime
metric: Literal["pm25", "temperature_c", "humidity_pct", "pressure_hpa"]
value: float
battery_v: float | None = Field(default=None, ge=0, le=6)
@field_validator("observed_at")
@classmethod
def _sane_time(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("observed_at must be timezone-aware (UTC)")
now = datetime.now(timezone.utc)
if v > now + MAX_FUTURE:
raise ValueError(f"observed_at is {v - now} in the future")
if v < now - MAX_AGE:
raise ValueError(f"observed_at is {now - v} old — replay or clock fault")
return v.astimezone(timezone.utc)
@field_validator("value")
@classmethod
def _physically_possible(cls, v: float, info) -> float:
limits = {
"pm25": (0.0, 2000.0), # sensor saturates well below this
"temperature_c": (-60.0, 90.0), # beyond the datasheet range either way
"humidity_pct": (0.0, 100.0),
"pressure_hpa": (300.0, 1100.0),
}
metric = info.data.get("metric")
if metric is None: # metric already failed; do not mask that error
return v
lo, hi = limits[metric]
if not (lo <= v <= hi):
raise ValueError(f"{metric}={v} outside instrument range [{lo}, {hi}]")
return v
Two configuration choices carry weight. extra="forbid" turns an unexpected field into an error
rather than silently discarding it — which is how you find out that a firmware update started
sending pm10 alongside pm25 instead of discovering it a quarter later. And rejecting naive
datetimes at the boundary means the timezone discipline described in
handling timezone drift
is enforced by the type system rather than by convention.
The dead-letter path is the other half of the implementation. A rejection that is not recorded is data loss with extra steps:
from dataclasses import dataclass
@dataclass(frozen=True)
class Rejected:
raw: dict
reason: str
field: str | None
def parse(raw: dict) -> tuple[SensorReading | None, Rejected | None]:
"""Validate one payload. Never raises: a failure is a value, not an exception."""
try:
return SensorReading.model_validate(raw), None
except ValidationError as exc:
first = exc.errors()[0]
field = ".".join(str(p) for p in first["loc"]) or None
return None, Rejected(raw=raw, reason=first["msg"], field=field)
Returning the failure instead of raising it matters in a streaming consumer: an exception that
escapes the message handler either kills the consumer or, worse, is caught by a broad except two
frames up that logs nothing and acknowledges the message anyway.
Parameter Tuning Guide
| Metric | Instrument limits | Reject outside | Typical real range | Flag, do not reject |
|---|---|---|---|---|
| PM2.5 (µg/m³) | 0–1 000 (optical) | 0–2 000 | 2–60 | above 150 during smoke |
| Temperature (°C) | −40 to +85 | −60 to +90 | −10 to +38 | above 45 in direct sun |
| Relative humidity (%) | 0–100 | 0–100 | 25–95 | exactly 0 or 100 (saturated) |
| Pressure (hPa) | 300–1 100 | 300–1 100 | 950–1 040 | below 950 in a storm |
| Dissolved oxygen (mg/L) | 0–20 | 0–25 | 4–12 | below 2 (hypoxia — real) |
| Battery (V) | 0–6 | 0–6 | 3.2–4.2 | below 3.3 (imminent failure) |
The last column is the discipline this table exists to enforce. Every value in it is real, alarming and worth keeping — and every one of them would be deleted by a validator whose bounds were set from “what we usually see”.
Verification and Testing
Test the boundary, not the happy path. The cases that matter are the ones where reject-versus-flag is a judgement call.
# python 3.11 · pydantic==2.7.1 · pytest==8.2.0
import pytest
from datetime import datetime, timezone
def _payload(**over):
base = {
"device_id": "eui-70b3d5",
"observed_at": datetime.now(timezone.utc).isoformat(),
"metric": "pm25",
"value": 18.4,
}
base.update(over)
return base
def test_extreme_but_real_value_is_accepted():
reading, rejected = parse(_payload(value=612.0)) # severe wildfire smoke
assert rejected is None and reading.value == 612.0
def test_impossible_value_is_rejected_with_a_reason():
reading, rejected = parse(_payload(value=41000.0))
assert reading is None
assert "outside instrument range" in rejected.reason
assert rejected.field == "value"
def test_naive_timestamp_is_rejected():
_, rejected = parse(_payload(observed_at="2026-08-01T10:00:00"))
assert rejected is not None and "timezone-aware" in rejected.reason
def test_unknown_field_is_rejected_not_ignored():
_, rejected = parse(_payload(pm10=22.0))
assert rejected is not None and rejected.field == "pm10"
In production, the metric to watch is the rejection rate per reason code. A steady 0.1%
unknown_field rate is provisioning noise; the same rate jumping to 40% overnight is a firmware
rollout, and it will be obvious within minutes if the reason code is a labelled counter rather
than a log line.
Gotchas
float accepts "NaN" and "Infinity" from JSON. Python’s json module parses both by
default, and Pydantic’s float coercion accepts them. A NaN that reaches a database column poisons
every average computed from it. Add an explicit math.isfinite check, or configure the JSON parser
with parse_constant to raise.
Validating before decoding hides the real error. If a LoRaWAN payload fails to decode, the resulting dict is missing fields and the validator reports “field required” — which sends whoever is debugging to the wrong layer. Decode first, and let the decoder produce its own error class.
A permissive model with every field | None validates nothing. It is tempting when payload
shapes vary by model, and it converts every schema failure into a silent null. Use a discriminated
union on the sensor model instead, so each variant states exactly what it requires.
Bounds copied from the expected range rather than the datasheet. Worth repeating because it is the mistake with the highest cost: a validator tuned to typical values deletes exactly the episodes — the smoke event, the flood, the hypoxic night — that justify the network’s existence.
FAQ
Should validation reject a reading or flag it?
Reject only what is structurally unusable — a missing timestamp, an unparseable number, a coordinate outside the globe. Anything that is well-formed but implausible is a quality question, not a schema question, and belongs in the QC flagging stage where it gets a flag code and stays in the dataset. A validator that drops a genuine 400 µg/m³ reading during a wildfire has destroyed the most important data of the year.
Is Pydantic fast enough for a high-rate stream?
Yes. Pydantic 2 validates in compiled Rust and handles roughly 100 000 simple models per second per core, which is far beyond the arrival rate of any environmental network. If you are genuinely constrained, validate at batch granularity with TypeAdapter over a list rather than per message — that alone roughly doubles throughput.
How do I validate a payload whose fields vary by sensor model?
Use a discriminated union keyed on the model field. Each variant gets its own model with its own ranges and required fields, and Pydantic picks the right one from the discriminator without a chain of try/except. It also produces a far better error message than a single permissive model with every field optional.
Related
- Sensor Metadata and Device Registry Management — the registry stage this validator feeds
- Modelling Sensor Deployment History with Validity Intervals — the join that runs on the readings this guard lets through
- Automating QC Flags for Missing Environmental Readings — where implausible-but-well-formed values are handled instead