Data Quality Gates & Monitoring for Field Datasets

The failure that costs the most in a field pipeline is not a crash. It is a run that succeeds, produces a container that opens cleanly, and contains four…

The failure that costs the most in a field pipeline is not a crash. It is a run that succeeds, produces a container that opens cleanly, and contains four per cent of the features it should — because a source directory was empty, a filter matched nothing, or a schema change dropped a column and nobody looked at the count. Every automated check in the world for structural correctness passes on that file. It is structurally perfect and substantively wrong.

This guide is part of the CLI Automation & Offline Sync Pipelines section. It covers the checks that catch substantive failures: what a quality gate should assert, how to detect drift between cycles, and why comparing against the previous run finds more real problems than any absolute threshold.

Prerequisites

Concept & Specification Reference

Quality checks divide into three kinds, and they answer genuinely different questions.

KindQuestionWhere the answer comes from
StructuralIs this a valid container?The specification
AbsoluteIs this dataset self-consistent?Rules you state
RelativeIs this dataset like the last one?The previous run

Structural checks are objective and cheap, and they catch corruption and non-conformance. Absolute checks encode domain rules — every parcel has a reference, no observation is dated in the future, every geometry falls inside the survey extent — and they catch data that is wrong on its own terms. Relative checks compare against the previous successful run, and they catch the largest class of real failures: everything that is individually plausible and collectively unlike anything that has happened before.

Most pipelines implement the first kind, some implement the second, and the third is where the missing coverage almost always is.

Three kinds of quality check and what each one catchesStructural checks catch corruption and non-conformance, and are decided by the specification. Absolute checks catch data that violates stated domain rules, such as a missing reference or a future date. Relative checks compare the run against the previous successful one and catch the largest class of real failures: a dataset that is individually plausible but collectively unlike anything seen before, such as a feature count that dropped by ninety per cent.structuralregistry, geometry, indexthe specification decidescatches corruptionnearly always implementedabsolutedomain rules you stateno future dates, no nullscatches wrong valuessometimes implementedrelativecompared with last runcounts, extent, schemacatches plausible nonsenserarely implementeda run with 4% of the expected features passes the first twoand fails only the third
The rightmost column is where the coverage gap usually is, and it is also the cheapest of the three to add.

Step-by-Step Implementation

1. Emit a manifest from every run

A relative check needs something to compare against, and that something has to be produced by every run whether or not anyone looks at it. A manifest is a few dozen lines of JSON and is the foundation everything else here builds on.

bash
#!/usr/bin/env bash
# Emit a manifest describing what this run produced
set -euo pipefail
DB=${1:?usage: manifest.sh <container>}

layers=$(sqlite3 "$DB" \
    "SELECT group_concat(table_name) FROM gpkg_contents WHERE data_type='features';")

{
  printf '{\n'
  printf '  "container": "%s",\n' "$(basename "$DB")"
  printf '  "bytes": %s,\n' "$(stat -c%%s "$DB")"
  printf '  "layers": {\n'
  first=1
  IFS=',' read -ra names <<< "$layers"
  for name in "${names[@]}"; do
      n=$(sqlite3 "$DB" "SELECT count(*) FROM \"$name\";")
      [ $first -eq 0 ] && printf ',\n'
      printf '    "%s": %s' "$name" "$n"
      first=0
  done
  printf '\n  }\n}\n'
} > "${DB%.gpkg}.manifest.json"

Keep the manifest beside the artefact rather than in a log. Logs rotate; the manifest needs to survive as long as the container it describes, because the next run will read it.

2. Assert the absolute rules

Absolute rules are domain statements, and the best of them are the ones a surveyor would recognise. Express them as queries that return the offending rows, not as booleans — a gate that says “failed” is much less useful than one that says which twelve rows failed.

sql
-- Domain assertions: each returns the rows that violate the rule
-- Every parcel must carry a reference
SELECT fid, 'missing parcel_ref' AS problem
FROM parcels WHERE parcel_ref IS NULL OR trim(parcel_ref) = '';

-- No observation may be dated in the future
SELECT fid, 'future date: ' || surveyed_on AS problem
FROM observations WHERE surveyed_on > date('now');

-- Every geometry must fall inside the declared survey extent
SELECT fid, 'outside survey extent' AS problem
FROM parcels
WHERE ST_MinX(geom) < -8.2 OR ST_MaxX(geom) > 1.8
   OR ST_MinY(geom) < 49.9 OR ST_MaxY(geom) > 60.9;

-- No duplicate references
SELECT parcel_ref, 'duplicated ' || count(*) || ' times' AS problem
FROM parcels GROUP BY parcel_ref HAVING count(*) > 1;

The extent check is worth singling out. It is the cheapest possible detector for a whole family of projection and datum errors, because a coordinate that has been mangled almost always lands outside the area the survey covers — often by an enormous margin, since a projected coordinate read as degrees ends up far off the map.

3. Compare against the previous run

This is the step that catches what the other two miss. The comparison does not need to be clever; a ratio against the last manifest finds nearly everything worth finding.

python
# Compare this run's manifest against the last successful one
import json
import sys
from pathlib import Path

TOLERANCE = 0.10   # a layer may move by 10% between runs without comment


def compare(current: Path, previous: Path) -> list[str]:
    now = json.loads(current.read_text())
    was = json.loads(previous.read_text())
    problems = []

    missing = set(was["layers"]) - set(now["layers"])
    if missing:
        problems.append(f"layers disappeared: {sorted(missing)}")

    added = set(now["layers"]) - set(was["layers"])
    if added:
        problems.append(f"layers appeared: {sorted(added)}")

    for name, n_was in was["layers"].items():
        n_now = now["layers"].get(name)
        if n_now is None or n_was == 0:
            continue
        drift = (n_now - n_was) / n_was
        if abs(drift) > TOLERANCE:
            problems.append(
                f"{name}: {n_was} -> {n_now} ({drift:+.1%})"
            )

    size_drift = (now["bytes"] - was["bytes"]) / max(was["bytes"], 1)
    if abs(size_drift) > 0.5:
        problems.append(f"container size moved {size_drift:+.1%}")

    return problems


if __name__ == "__main__":
    issues = compare(Path(sys.argv[1]), Path(sys.argv[2]))
    for line in issues:
        print("DRIFT:", line)
    sys.exit(1 if issues else 0)

A ten per cent tolerance is a starting point, not a law. A layer that grows steadily wants a tighter band; one that is genuinely bursty wants a looser one, or a comparison against a rolling median rather than the single previous run.

Feature counts across seven nightly runs, with one that should have failedSeven nightly runs of the same layer. Counts hold steady between about twelve and thirteen thousand for six runs, then drop to under six hundred on the seventh. Nothing about the seventh run is structurally wrong and every individual row in it is valid, but the count is a fraction of every previous run. A relative check catches it immediately; a structural or absolute check does not notice at all.parcels layer, feature count per nightly run12,410Mon12,733Tue12,880Wed12,951Thu13,004Fri13,011Sat571Sunevery row on Sunday is valid — the run is still wrong
No structural or absolute check fires on the last bar. Only the comparison against the previous run does.

4. Detect schema drift

A column that changes type, disappears, or appears unannounced is a break in the contract with every consumer. Comparing the schema between runs is cheap and catches it before the consumer does.

python
# Record and compare the column signature of every layer
import sqlite3


def schema_signature(path: str) -> dict[str, list[tuple[str, str]]]:
    conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
    try:
        layers = [r[0] for r in conn.execute(
            "SELECT table_name FROM gpkg_contents WHERE data_type='features'"
        )]
        return {
            layer: [
                (row[1], row[2])            # (column name, declared type)
                for row in conn.execute(f'PRAGMA table_info("{layer}")')
            ]
            for layer in sorted(layers)
        }
    finally:
        conn.close()


def schema_diff(now: dict, was: dict) -> list[str]:
    out = []
    for layer, cols_was in was.items():
        cols_now = now.get(layer)
        if cols_now is None:
            out.append(f"{layer}: layer removed")
            continue
        set_was, set_now = set(cols_was), set(cols_now)
        for col in sorted(set_was - set_now):
            out.append(f"{layer}: column removed or retyped: {col}")
        for col in sorted(set_now - set_was):
            out.append(f"{layer}: column added or retyped: {col}")
    return out

Comparing name and declared type together means a column that silently changed from INTEGER to REAL — the dtype-drift failure described in How to Append a GeoDataFrame to a GeoPackage Layer — shows up as both a removal and an addition, which is exactly the signal you want.

5. Check that the indexes still agree with the data

A stale spatial index is invisible to every other check here: the container is conforming, the counts are right, the schema is unchanged, and spatial queries return too few rows. Comparing entry counts is a single query per layer.

sql
-- One row per indexed layer; any mismatch is a stale index
SELECT 'parcels' AS layer,
       (SELECT count(*) FROM rtree_parcels_geom) AS index_rows,
       (SELECT count(*) FROM parcels WHERE geom IS NOT NULL) AS geom_rows;

Where counts match but queries still misbehave, the extents are stale rather than the entries — the case described in How to Automate R-tree Index Rebuilds After Bulk Load, which a count comparison cannot detect and a recovery rebuild fixes unconditionally.

6. Wire the gate into the publication step

A check that runs after publication is a report. A check that runs before it is a gate, and only the second one prevents anything.

bash
#!/usr/bin/env bash
# Publish only if every band passes
set -euo pipefail
TMP=$1 DEST=$2 PREV_MANIFEST=$3

sqlite3 "$TMP" "PRAGMA integrity_check;" | grep -q '^ok$'   # structural
python3 assert_domain_rules.py "$TMP"                        # absolute
./manifest.sh "$TMP"
python3 compare_manifests.py "${TMP%.gpkg}.manifest.json" "$PREV_MANIFEST"  # relative

sqlite3 "$TMP" "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
mv -f "$TMP" "$DEST"
cp -f "${TMP%.gpkg}.manifest.json" "${DEST%.gpkg}.manifest.json"

With set -e, any failing band aborts before the mv, and the previously published artefact stays in place. That is the behaviour that matters: a failed run should leave consumers with yesterday’s good data rather than today’s bad data.

Validation & Verification

The gate itself needs testing, and the test is to feed it something it must refuse. This is the same discipline the rest of this site applies to assertions — a check that has never rejected anything has not been shown to check anything.

bash
# Prove each band actually rejects
cp good.gpkg broken.gpkg

# structural: corrupt a registry table
sqlite3 broken.gpkg "DROP TABLE gpkg_geometry_columns;"
./publish.sh broken.gpkg /srv/out.gpkg prev.json && echo "GATE FAILED TO STOP THIS"

# absolute: introduce a future date
cp good.gpkg broken.gpkg
sqlite3 broken.gpkg "UPDATE observations SET surveyed_on='2099-01-01' WHERE fid=1;"
./publish.sh broken.gpkg /srv/out.gpkg prev.json && echo "GATE FAILED TO STOP THIS"

# relative: delete most of a layer
cp good.gpkg broken.gpkg
sqlite3 broken.gpkg "DELETE FROM parcels WHERE fid > 100;"
./publish.sh broken.gpkg /srv/out.gpkg prev.json && echo "GATE FAILED TO STOP THIS"

Run this once when the gate is written, and again whenever a band is changed. It takes a couple of minutes and is the only evidence that any of it works.

A gate stops publication; a report does notTwo placements of the same checks. Running them before the atomic rename means a failure leaves the previously published artefact in place and consumers never see the bad data. Running them after publication means the checks produce a report about data consumers are already using. The checks are identical; only the position relative to the rename differs.checks before the renamefailure aborts publicationyesterday's artefact staysconsumers never see itthis is a gatechecks after the renamefailure produces a messagebad artefact already livedevices may have synced itthis is a report
Same assertions, same cost — the only difference is whether they run before or after the artefact becomes visible.

Common Failure Modes & Fixes

The gate fires constantly and gets disabled

Diagnosis: Tolerances set from a single run rather than from observed variability, so ordinary fluctuation trips them. Fix: Collect manifests for a couple of weeks before enforcing, set the band from what actually happened, and separate warnings from failures. A gate that fires weekly and is right once a quarter will be switched off before that quarter ends.

A legitimate large change is blocked

Diagnosis: A genuine bulk import or a deliberate reprocessing moved counts far outside the band. Fix: Provide an explicit override that records why — a flag that writes the reason into the manifest. An override with a reason is auditable; commenting out the check is not, and is what happens if no override exists.

Drift is detected but nobody can say what changed

Diagnosis: The manifest records too little — a total row count and nothing else. Fix: Record per layer rather than per container, and include the extent and schema signature. The extra data costs nothing and turns “something moved” into “the parcels layer lost its western half”, which is a diagnosis rather than an alert.

The first run of a new pipeline cannot pass

Diagnosis: There is no previous manifest to compare against, and the relative band treats absence as failure. Fix: Treat a missing baseline as a pass with a recorded note. The first run establishes the baseline by definition; only the second run onward can be compared.

Schema drift is reported for a column that did not change

Diagnosis: The declared type string differs while the effective type does not — VARCHAR(32) against TEXT, or INT against INTEGER. SQLite’s type affinity treats these as equivalent and PRAGMA table_info does not. Fix: Normalise the declared type to its affinity before comparing, so the signature reflects behaviour rather than spelling.

Performance Notes

Nearly everything here is cheap. Counts, schema signatures and registry queries are index reads or table-info lookups, and a manifest for a container with a dozen layers takes well under a second.

Two things are not cheap, and both are worth scoping deliberately. PRAGMA integrity_check reads every page of the container, so on a large file it is a full disk read — appropriate before publication, wasteful in a per-cycle sync check, and PRAGMA quick_check covers the common corruption cases at a fraction of the cost. Geometry-level assertions such as validity are also full scans with a topology test per row; where a change-log version exists, run them only over rows above the last checked watermark, using the incremental pattern from Tracking Row Changes for Incremental GeoPackage Sync.

The extent check deserves a note of its own: it is the cheapest high-value assertion available, because ST_MinX and its siblings read the geometry header rather than decoding coordinates. It catches a large family of projection errors for close to nothing.

Child Pages

Pages in this section go deeper on individual monitoring tasks:

Frequently Asked Questions

Should a quality gate block publication or just warn?

Block, for anything that makes the artefact unusable or unlike the previous one, and warn for everything else — but the split has to be decided in advance rather than argued about during an incident. The practical test is what a consumer would prefer: yesterday’s good data, or today’s data that might be wrong. For a field deployment the answer is nearly always yesterday’s, because a surveyor working from stale data knows it is stale, and one working from wrong data does not.

How many previous runs should the comparison use?

One is enough to start and is what most pipelines should keep. A single previous manifest catches the large, obvious break, which is the failure that actually happens. Comparing against a rolling window catches slow drift as well, and is worth the extra state where a dataset trends genuinely — a layer growing five per cent a month will eventually trip a fixed band against a single previous run, and a rolling median will not.

Where should the manifest live?

Next to the artefact, in the same directory, published by the same atomic step. Keeping it elsewhere — a database, a monitoring system, a log — means the two can be separated, and a container whose manifest is missing cannot be checked against anything. A JSON file beside the .gpkg also survives being copied to a field device, which means the device can verify what it received.

Is `PRAGMA integrity_check` worth running every cycle?

Not on a large container. It reads every page, which on a multi-gigabyte file is minutes of I/O to detect something that essentially only happens after a hardware fault or an interrupted write. Run it before publication, where the cost is justified by what it prevents, and use quick_check for routine cycles — it catches the common corruption patterns without the full read.

What is the single most valuable check to add first?

A per-layer feature count compared against the previous run. It costs one query per layer, needs no domain knowledge, and catches the empty-source, matched-nothing and dropped-filter failures that account for most real incidents. Everything else here is worth adding; that one is worth adding today.