Pinning a Schema Fingerprint for PostGIS Tables
A schema contract that lives in a document is a description. A schema fingerprint is an assertion: a single hash computed from the table’s structure that either matches the pinned value or does not, evaluated on every load, with no room for interpretation. Spatial tables need this more than most, because their structure carries meaning that ordinary schema checks miss — the geometry column’s declared type, its spatial reference identifier, its dimensionality, and the constraints that enforce them are all part of the contract, and all of them can change without a single column name moving. This guide covers how to compute a fingerprint that captures what matters for PostGIS, how to pin and version it, and what to do when it breaks. It belongs to schema and attribute drift detection under spatial data freshness and quality metrics.
Problem framing: what belongs in the fingerprint
Include what changes behaviour; exclude what changes with time or environment.
Include the column set with names, ordinal positions, types and nullability; the geometry column’s spatial reference identifier, declared geometry type and coordinate dimension; the presence and type of the spatial index; and any check constraints that enforce validity, type or projection. Each of these silently changes what the table accepts or how it performs.
Exclude row counts, table size, statistics, index bloat and last-vacuum times. These change constantly and have nothing to do with structure; including them produces a fingerprint that never matches and is therefore never checked.
Think carefully about ordinal position. Including it catches column reordering, which matters for INSERT ... SELECT without an explicit column list and for tools that map positionally. Excluding it makes the fingerprint tolerant of a harmless reorder. Most spatial platforms should include it, because positional loads are common in bulk ingestion and a silent reorder is exactly the defect that corrupts a whole batch.
A related judgement is whether to include the spatial index type. Switching a GiST index for BRIN changes query planning dramatically without changing a single column, and on a table whose queries depend on bounding-box selectivity that is a behavioural change worth catching — the trade-off compared in GiST vs BRIN indexes for spatial observability.
One further judgement is worth making explicitly: how much of the constraint definition to include. Hashing the full text of every check constraint is the strictest option and it makes the fingerprint sensitive to cosmetic rewrites — reformatting an expression changes the hash without changing behaviour. Hashing only the constraint’s type and the columns it touches is more tolerant and misses a genuinely loosened bound. For spatial tables the strict form is usually right, because the constraints that matter most are the SRID and geometry-type enforcement whose text rarely changes for cosmetic reasons, and a spurious mismatch caused by a reformat is cheap to diagnose compared with a silently relaxed projection constraint.
Implementation: compute the hash
Assemble the structural facts in a deterministic order and hash the result. Ordering is not optional — an unordered aggregate produces a different hash for identical structure.
CREATE OR REPLACE FUNCTION audit.schema_fingerprint(p_schema text, p_table text)
RETURNS text LANGUAGE sql STABLE AS $$
WITH cols AS (
SELECT string_agg(
format('%s:%s:%s:%s:%s',
c.column_name, c.ordinal_position, c.data_type,
c.is_nullable, COALESCE(c.column_default, '-')),
'|' ORDER BY c.ordinal_position) AS sig -- ORDER BY is mandatory
FROM information_schema.columns c
WHERE c.table_schema = p_schema AND c.table_name = p_table
),
geom AS (
-- The spatial half: SRID, declared type and dimension per geometry column.
SELECT string_agg(
format('%s:srid=%s:type=%s:dims=%s',
g.f_geometry_column, g.srid, g.type, g.coord_dimension),
'|' ORDER BY g.f_geometry_column) AS sig
FROM geometry_columns g
WHERE g.f_table_schema = p_schema AND g.f_table_name = p_table
),
idx AS (
-- Index name is excluded deliberately; the access method and column are what
-- change behaviour, and index names churn across environments.
SELECT string_agg(format('%s:%s', am.amname, i.indkey::text),
'|' ORDER BY am.amname, i.indkey::text) AS sig
FROM pg_index i
JOIN pg_class ci ON ci.oid = i.indexrelid
JOIN pg_am am ON am.oid = ci.relam
JOIN pg_class ct ON ct.oid = i.indrelid
JOIN pg_namespace n ON n.oid = ct.relnamespace
WHERE n.nspname = p_schema AND ct.relname = p_table
),
cons AS (
SELECT string_agg(pg_get_constraintdef(c.oid), '|' ORDER BY conname) AS sig
FROM pg_constraint c
JOIN pg_class ct ON ct.oid = c.conrelid
JOIN pg_namespace n ON n.oid = ct.relnamespace
WHERE n.nspname = p_schema AND ct.relname = p_table
AND c.contype IN ('c', 'p', 'u') -- check, primary, unique
)
SELECT md5(concat_ws('||',
COALESCE(cols.sig, ''), COALESCE(geom.sig, ''),
COALESCE(idx.sig, ''), COALESCE(cons.sig, '')))
FROM cols, geom, idx, cons;
$$;
Pin the value in the layer registry beside the contract, not in the database, so that a change to the table cannot silently update its own expectation.
-- The check, run before every promotion.
SELECT audit.schema_fingerprint('prod', 'parcels') = :pinned_fingerprint AS ok;
-- false → halt. A structural break is never a warning.
Verification
Change one thing at a time and confirm the fingerprint moves. Add a nullable column, then revert; drop the SRID constraint, then restore it; swap the spatial index access method, then swap it back. Each should produce a different hash and the revert should restore the original exactly. A change that does not move the hash is a gap in the fingerprint’s coverage, and the SRID case is the one most often missing.
Then confirm stability. Run the function twice in a row and across a replica, and confirm identical output. Instability usually means an unordered aggregate or the inclusion of an environment-specific detail such as an index name or an OID.
Gotchas
Unordered aggregation. Different hash for identical structure; the single most common defect in fingerprint implementations.
Including index names. They differ between environments and after a concurrent rebuild, producing spurious mismatches. Hash the access method and column set instead.
Storing the pin in the database being checked. A migration that alters the table can update its own expectation. Keep the pin in the registry alongside the contract.
Re-pinning to clear an alert. The most damaging habit available. Re-pin only after diffing and understanding the change; a fingerprint that is updated whenever it breaks measures nothing.
Omitting the geometry column’s declared type. A column declared GEOMETRY accepts anything; one declared MULTIPOLYGON does not. Loosening the declaration is a real contract change and is invisible to ordinary column metadata.
FAQ
Should the fingerprint cover views as well as tables?
Cover the objects consumers actually read. If consumers read a view, its column list and the types it exposes are the contract, and a change to the underlying table that leaves the view identical is not a consumer-facing break. Fingerprinting both, separately, is the clearest arrangement.
How does this interact with contract versioning?
The fingerprint is one clause of the contract, so a fingerprint change follows the classification described in versioning spatial data contracts without breaking consumers: adding a nullable column loosens, dropping one is semantic, and changing a geometry type or SRID is always major.
What about partitioned tables?
Fingerprint the parent and assert that every partition matches it structurally. A partition that drifted — a different default, a missing constraint, an index that was never created — is a classic source of “the query is fast except in March”, and the per-partition check catches it.
Should a mismatch halt or warn?
Halt. A structural break means the load’s assumptions about the table are wrong, and continuing risks a positional misalignment that corrupts every row. This is one of the few checks where there is no defensible warning tier.
Can the fingerprint be computed outside the database?
It can be derived from a migration tool’s declared state, but computing it from the live catalogue is strictly better, because it measures what is actually there rather than what was intended. Drift between the two is itself worth knowing about.
A final operational habit worth adopting: emit the observed fingerprint as a metric label alongside the match result, not merely the boolean. Charting the observed value over time turns a structural change into a visible step on a graph, and a step that nobody scheduled is far easier to notice than a single alert that fired at three in the morning and was acknowledged without investigation. The label is bounded by construction, since a table has one structure at a time, so it costs nothing in cardinality terms.
One last habit worth adopting: record the fingerprint alongside every batch in the reconciliation ledger, not only at the moment of the check. When a later investigation asks whether a given batch loaded against the structure it expected, the answer is then a lookup rather than an inference from deployment history.
Related
- Schema and attribute drift detection — the parent topic covering structural and statistical drift.
- Detecting attribute drift in slowly changing layers — the statistical counterpart to this structural check.
- Spatial data contracts and SLO design — where the pinned value lives.