Detecting Attribute Drift in Published Layers

Geometry defects announce themselves; attribute drift does not. A producer’s export tool changes a field width, a code list gains a value nobody documented, a required attribute starts arriving null in three percent of features — and every geometry check passes, the layer publishes, and consumers discover it weeks later as an unexplained gap in a join. This guide builds the detector: fingerprint a layer’s attribute shape and distribution at publish time, compare each publish against the trailing baseline, and raise a producer-facing alert when the shape moves. It implements the drift tier of Spatial Data Quality and Validation Standards within Geospatial Data Mesh Fundamentals, and complements Measuring Positional Accuracy Against Control Points, which covers the geometric half.

Prerequisites

Requirement Value / Assumption Notes
Tools psql ≥ 14, python3 ≥ 3.11, jq No external statistics library needed
History ≥ 5 previous publishes of the layer The baseline is a trailing median, not a fixed value
Storage A quality_history table keyed on product and version Immutable; one row per publish
CRS convention Irrelevant here — attributes only Geometry checks run separately
Access roles platform-engineer (run), gis-data-steward (adjust thresholds) Thresholds are governed
Environment PRODUCT_ID, PARTITION, CATALOG_API Exported before running

Drift detection needs history, which means the first five publishes of a new layer produce a baseline rather than an alert. That is correct: a layer with no history has no normal to deviate from, and alerting against a fixed expectation on day one produces noise that teaches everyone to ignore the detector.

Step-by-Step Implementation

1. Fingerprint the attribute shape

The shape is the structural facts: which fields exist, their types, and whether they are nullable in practice. A change here is almost always a producer-side export change rather than a data change.

sql
-- attr_shape.sql — the structural fingerprint of one partition's attributes.
-- Parameters: :product (text), :partition (text)
SELECT
    key                                              AS field,
    jsonb_typeof(value)                              AS observed_type,
    count(*)                                         AS present,
    count(*) FILTER (WHERE value = 'null'::jsonb)    AS nulls,
    -- Distinct-value count is the cheapest signal that a code list changed.
    count(DISTINCT value)                            AS distinct_values,
    max(length(value::text))                         AS max_len
FROM product_features f,
     LATERAL jsonb_each(f.attrs)
WHERE f.product = :'product' AND f.partition_key = :'partition'
GROUP BY key, jsonb_typeof(value)
ORDER BY key;

Fingerprint, compare against the trailing median, route to the producerEach publish is fingerprinted into a compact per-field summary — type, null ratio, distinct count, cardinality ratio, maximum length — which is small enough to retain indefinitely and needs none of the data itself. The fingerprint is compared against a trailing median over recent publishes rather than against the previous one. Findings are routed to the producing domain, never to a platform dashboard. Two rails record the cases the detector deliberately does not act on: fewer than five publishes means there is no baseline, and drift never blocks a publish.Fingerprintper field, no dataTrailing medianlast 10 publishesFindingsto the producercomparedrouted< 5 publishes: no baselineReport nothing, build the baselinenever a day-one alert

Verify that the field set matches the product’s declared contract before looking at any distribution:

bash
psql --csv -f attr_shape.sql -v product="$PRODUCT_ID" -v partition="$PARTITION" \
  | cut -d, -f1 | tail -n +2 | sort -u > /tmp/observed_fields
curl -sS "$CATALOG_API/products/$PRODUCT_ID/contract" \
  | jq -r '.attributes[].name' | sort -u > /tmp/declared_fields
diff /tmp/declared_fields /tmp/observed_fields && echo "shape matches the contract"

2. Fingerprint the value distribution

Shape catches structural change; distribution catches the subtler case where the shape is unchanged and the content has moved — a code list that has quietly gained a category, a numeric field whose range has shifted.

python
# attr_fingerprint.py — a compact, comparable summary of one publish.
import csv
import hashlib
import json
import sys


def fingerprint(rows: list[dict]) -> dict:
    """One row per field. Everything here is comparable across publishes without
    retaining the data itself, which keeps history cheap and avoids duplicating
    a domain's attributes into the quality store."""
    out = {}
    for r in rows:
        total = int(r["present"])
        out[r["field"]] = {
            "type": r["observed_type"],
            "null_ratio": round(int(r["nulls"]) / total, 6) if total else 1.0,
            "distinct": int(r["distinct_values"]),
            # Cardinality ratio separates an identifier from a code list.
            "cardinality_ratio": round(int(r["distinct_values"]) / total, 6) if total else 0.0,
            "max_len": int(r["max_len"]),
        }
    digest = hashlib.sha256(
        json.dumps(sorted(out.keys()), separators=(",", ":")).encode()
    ).hexdigest()[:16]
    return {"fields": out, "shape_digest": digest}


if __name__ == "__main__":
    rows = list(csv.DictReader(open(sys.argv[1], newline="")))
    print(json.dumps(fingerprint(rows), indent=2))

Verify the fingerprint is stable across two runs over the same partition — a fingerprint that changes without the data changing is useless as a drift signal:

bash
psql --csv -f attr_shape.sql -v product="$PRODUCT_ID" -v partition="$PARTITION" > /tmp/a.csv
psql --csv -f attr_shape.sql -v product="$PRODUCT_ID" -v partition="$PARTITION" > /tmp/b.csv
diff <(python3 attr_fingerprint.py /tmp/a.csv) <(python3 attr_fingerprint.py /tmp/b.csv) \
  && echo "fingerprint is deterministic"

3. Compare against the trailing baseline

The baseline is a median over recent publishes, not the previous one. Comparing against a single prior publish makes every measurement a comparison against whatever noise that publish happened to carry.

python
# drift_check.py — compare one fingerprint against the trailing median.
import json
import statistics
import sys

# A field's null ratio moving by more than this, in absolute terms, is drift.
NULL_RATIO_DELTA = 0.02
# A distinct-value count moving by more than this factor is drift.
DISTINCT_FACTOR = 1.5


def baseline(history: list[dict]) -> dict:
    """Trailing median per field per statistic. Median rather than mean, so one
    bad publish in the history does not move the baseline it is compared against."""
    fields = {}
    for entry in history:
        for name, stats in entry["fields"].items():
            fields.setdefault(name, {"null_ratio": [], "distinct": []})
            fields[name]["null_ratio"].append(stats["null_ratio"])
            fields[name]["distinct"].append(stats["distinct"])
    return {
        name: {k: statistics.median(v) for k, v in vals.items()}
        for name, vals in fields.items()
    }


def drift(current: dict, base: dict) -> list[str]:
    findings = []
    for name, stats in current["fields"].items():
        if name not in base:
            findings.append(f"{name}: new field, absent from the baseline")
            continue
        b = base[name]
        if abs(stats["null_ratio"] - b["null_ratio"]) > NULL_RATIO_DELTA:
            findings.append(
                f"{name}: null ratio {b['null_ratio']:.3f} -> {stats['null_ratio']:.3f}")
        if b["distinct"] and stats["distinct"] > b["distinct"] * DISTINCT_FACTOR:
            findings.append(
                f"{name}: distinct values {b['distinct']:.0f} -> {stats['distinct']}")
    for name in base:
        if name not in current["fields"]:
            findings.append(f"{name}: field disappeared")
    return findings


if __name__ == "__main__":
    current = json.load(open(sys.argv[1]))
    history = [json.loads(line) for line in open(sys.argv[2])]
    if len(history) < 5:
        print("baseline building — no drift check until 5 publishes exist")
        raise SystemExit(0)
    findings = drift(current, baseline(history))
    for f in findings:
        print(f"DRIFT: {f}")
    # Drift is producer-facing, not blocking: exit 0 so publication proceeds.
    raise SystemExit(0)

A null-ratio movement that looks small and is notA required attribute null ratio across five publishes, as a percentage, against the two-percentage-point drift threshold. The ratio sits at 0.02 percent for the first three publishes, rises to 0.4 in publish four, and reaches 9.1 in publish five. Expressed as a ratio the last figure reads as a rounding detail; expressed in features it means roughly one in eleven now fails any join that depends on the field. Reporting the finding in features rather than in ratio is what makes a producer act on it.publish 1–30.02%publish 40.4%publish 59.1%≈4,200 featuresdrift threshold

Verify the detector fires by feeding it a synthetic change:

bash
python3 - <<'PY' > /tmp/drifted.json
import json
fp = json.load(open('/tmp/current.json'))
fp["fields"]["owner_ref"]["null_ratio"] = 0.09      # baseline near 0.00
json.dump(fp, open('/tmp/drifted.json', 'w'))
PY
python3 drift_check.py /tmp/drifted.json quality_history.jsonl   # expect a DRIFT line

4. Route the finding to the producing domain

Drift is a signal about an upstream source, so it belongs with the team that owns that source — not on a platform dashboard where nobody reads it.

bash
# Findings become an annotation on the publish, and a notification to the owning team.
python3 drift_check.py /tmp/current.json quality_history.jsonl \
  | jq -Rsc '{drift: (split("\n") | map(select(length > 0)))}' \
  | curl -sS -X PATCH "$CATALOG_API/products/$PRODUCT_ID/versions/$VERSION/quality" \
      -H 'content-type: application/json' -d @-

Configuration Reference

Parameter Scope Default Effect
NULL_RATIO_DELTA Detector 0.02 Absolute null-ratio movement treated as drift
DISTINCT_FACTOR Detector 1.5 Distinct-count growth factor treated as drift
Baseline window Detector Last 10 publishes Trailing median; shorter tracks change faster
Minimum history Detector 5 publishes Below this, build the baseline and report nothing
Blocking Governance false Drift is advisory; only validity blocks
Routing Governance Owning domain Never a central dashboard

Common Failure Modes & Fixes

Every publish reports drift on the same field. Root cause: a genuinely high-variance field — a timestamp, a running identifier — being compared as though it were a code list. Fix: exclude high-cardinality fields whose cardinality_ratio approaches 1.0; they carry no drift signal by construction.

Which fields carry a drift signal and which are noise by constructionFour field kinds against their cardinality ratio, whether they carry a usable drift signal, and how to treat them. A code list has low cardinality and a strong signal: a growing distinct count means the list changed upstream. A required reference has low cardinality and a strong null-ratio signal. A free-text description has moderate cardinality and weak signal. A timestamp or running identifier has a cardinality ratio near one and no signal at all — it should be excluded, or it will report drift on every publish.Cardinality ratioSignalTreatmentcode listlowstrongwatch distinct countrequired referencelowstrongwatch null ratiofree textmoderateweakwatch max length onlytimestamp / running id≈ 1.0noneexclude

Drift appears immediately after a contract change and never clears. Root cause: the baseline still contains publishes from before the change, so the median straddles two regimes. Fix: reset the baseline at a MAJOR contract version boundary. A deliberate change should not be reported as drift forever.

A field disappears with no alert. Root cause: the detector compares fields present in the current publish and never iterates the baseline. Fix: the reverse pass in drift() above; missing fields are the highest-value finding and the easiest to omit.

Null ratio jumps from 0.00 to 0.03 and the team dismisses it. Root cause: three percent looks small. It is not — it means one feature in thirty now fails any join that depends on the field. Fix: express the finding in features rather than ratio when reporting to a producer; “4,200 features now have no owner reference” lands where “0.03” does not.

The detector is deterministic locally and noisy in CI. Root cause: the fingerprint query has no ORDER BY and the aggregation is being computed over a partition still receiving writes. Fix: run the fingerprint against the immutable published artifact rather than against the live table.

FAQ

Why compare against a trailing median rather than the contract?

The contract states what must be true; the baseline states what has been true. Both matter and they catch different things. A contract violation — a required field absent, a type change — is a hard failure and should block, and that check is separate. Drift is about movement within what the contract permits: the contract says owner_ref is optional, and it has been present on 99.98% of features for six months, and this week it is present on 91%. Nothing is violated, and something has clearly changed upstream. Only a comparison against history can see that, and only a producer can explain it.

Should drift ever block a publish?

Almost never, and the exception is narrow. Blocking on drift means a legitimate change in the world — a new administrative code, a genuine expansion of a code list — stops publication until a human intervenes, which trains teams to widen thresholds until the detector is inert. The narrow exception is a shape change that is also a contract violation, and that is properly the contract gate’s job rather than the drift detector’s. Keep drift advisory, route it to the producer, and let them decide whether the change was intended.

How do I stop the baseline from absorbing a slow, real regression?

Use a median over a window long enough to span the regression, and review the baseline itself periodically rather than only the deltas. A field whose null ratio has crept from 0.00 to 0.06 over eight months will never trigger a per-publish delta of 0.02, and the trailing median will follow it up. The remedy is a second, slower check comparing the current baseline against the baseline from a year ago — cheap to compute from the same history, and the only thing that catches drift slower than the detector’s own window.

Does this need the data, or only the fingerprint?

Only the fingerprint, which is the point. Retaining shape and distribution summaries keeps the quality history small, avoids duplicating a domain’s attributes into a platform store, and sidesteps the access-control question that copying data would raise. It also means the history is cheap enough to retain indefinitely, which is what makes the year-over-year comparison above possible.