Detecting schema drift in OTA payloads
OTA providers change their payloads without asking. A field gets renamed, a nested object gains a level, a string becomes a number — and if your pipeline notices only when prices go wrong, the drift has already cost you. This guide builds a schema-drift detector that flags contract-breaking changes at ingestion time, implementing the drift-detection concern of Data Quality & Schema Contracts within the Data Ingestion & OTA API Integration Workflows pillar.
Prerequisites
- Python 3.11+
- Standard library
logging,dataclasses,hashlib - A registered schema contract (field names and types) per feed source
- A sample of recent valid payloads to seed the expected field set
- A sink for drift alerts (your observability stack)
Step 1 — Capture the expected field signature
Drift is a diff against an expected signature. Represent the contract as a set of field_path -> type_name pairs, flattening nested objects so a change at any depth is visible. Seeding it from the contract (not from live traffic) keeps the baseline authoritative.
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger("ingestion.drift")
def flatten_types(obj: Any, prefix: str = "") -> dict[str, str]:
"""Map each leaf path to its Python type name."""
out: dict[str, str] = {}
if isinstance(obj, dict):
for key, value in obj.items():
out.update(flatten_types(value, f"{prefix}{key}."))
elif isinstance(obj, list):
if obj: # inspect first element as representative
out.update(flatten_types(obj[0], f"{prefix}[]."))
else:
out[prefix.rstrip(".")] = type(obj).__name__
return out
Step 2 — Diff a live payload against the expected signature
Compare each incoming payload’s signature to the expected one, classifying differences as added fields, removed fields, or type changes. Type changes and removals of required fields are breaking; additions are usually compatible but still worth reporting.
from dataclasses import dataclass, field
@dataclass
class Drift:
added: dict[str, str] = field(default_factory=dict)
removed: dict[str, str] = field(default_factory=dict)
retyped: dict[str, tuple[str, str]] = field(default_factory=dict)
def is_breaking(self, required: set[str]) -> bool:
return bool(self.retyped) or bool(set(self.removed) & required)
def detect_drift(expected: dict[str, str], payload: dict) -> Drift:
actual = flatten_types(payload)
drift = Drift()
for path, typ in actual.items():
if path not in expected:
drift.added[path] = typ
elif expected[path] != typ:
drift.retyped[path] = (expected[path], typ)
for path, typ in expected.items():
if path not in actual:
drift.removed[path] = typ
return drift
Step 3 — Alert on breaking drift, log the rest
Route breaking drift to an alert immediately — a retyped price field is a production incident — while recording compatible additions for review so the contract can be extended deliberately.
def check_payload(expected: dict[str, str], required: set[str], payload: dict) -> bool:
drift = detect_drift(expected, payload)
if drift.is_breaking(required):
logger.error("breaking schema drift: retyped=%s removed=%s",
drift.retyped, {k: drift.removed[k] for k in set(drift.removed) & required})
return False
if drift.added or drift.removed:
logger.info("compatible drift: added=%s removed=%s", drift.added, drift.removed)
return True
Separating breaking from compatible drift is the judgment the pipeline needs: a new optional field should not page anyone at 3 a.m., but a base_rate that arrived as a string instead of a number absolutely should.
Verification and testing
def test_retyped_price_is_breaking() -> None:
expected = {"property_id": "str", "base_rate": "int", "currency": "str"}
required = {"property_id", "base_rate"}
good = {"property_id": "h1", "base_rate": 18000, "currency": "USD"}
bad = {"property_id": "h1", "base_rate": "18000", "currency": "USD"} # string!
assert check_payload(expected, required, good) is True
assert check_payload(expected, required, bad) is False
The failing case is the one that matters: a silently stringified rate is caught at the gate instead of surfacing as mispriced inventory hours later.
Common pitfalls and edge cases
- Empty lists hide element drift.
flatten_typescannot infer a list element’s type when the list is empty; treat missing element types as unknown, not as removed. - Nullable fields. A field that is sometimes null flips type to
NoneType; allow a declared set of types per path rather than a single one. - Sampling one element. Inspecting only the first list element misses heterogeneous arrays; sample several in high-risk feeds.
- Alert fatigue. Treating every added field as breaking trains people to ignore alerts; reserve paging for retypes and required removals.
- Baseline from live traffic. Seeding the expected signature from live data bakes in existing drift; seed from the reviewed contract.
Related
- Data Quality & Schema Contracts — the parent cluster defining the contracts this detector diffs against.
- Quarantining invalid records with a dead-letter queue — where records that fail after drift are held for replay.
- Troubleshooting OTA integration failures — the broader failure playbook drift detection feeds into.