Best Practices for Defining Trust Boundaries in PostGIS
A trust boundary in PostGIS is the exact point at which a spatial row stops being “incoming data” and becomes a fact that downstream queries, tiles, and cross-region replicas are allowed to depend on. This page narrows in on a single operational problem: deciding where in a PostGIS node that line is drawn, what validation and access checks enforce it, and how you instrument it so a breach surfaces as a metric instead of a 2 a.m. topology incident. It sits under Monitoring Topology for Multi-Region GIS — where these boundaries become the egress control points between regions — and within the broader Geospatial Observability Architecture & Fundamentals discipline. It is written for the data engineers, GIS platform administrators, SREs, and compliance teams who own the gap between raw ingestion and trusted production geometry.
Problem Framing
Tabular trust boundaries are usually a single concern — authorization. Spatial trust boundaries are three concerns stacked on the same row: geometric validity (is the polygon self-intersecting?), referential correctness (is it in the SRID the rest of the stack assumes?), and provenance (is this source allowed to write into a production feature class?). When any one of these is unguarded, the failure is silent. A self-intersecting ring passes a NOT NULL check, lands in public.cadastral_parcels, and only manifests hours later as a ST_Intersection returning an empty geometry or a GiST index that refuses to prune.
The signals that tell you a boundary is missing or leaking are specific: a rising count of rows where ST_IsValid(geom) is false, spatial queries whose mean execution time climbs as the planner falls back to sequential scans, and CRS-tagged metrics that disagree between the ingestion and publishing stages. The affected pipeline stage is almost always the transformation tier — reprojection, topology validation, and spatial joins — because that is where an unvalidated upstream feed first gets trusted by an expensive operation. Drawing the boundary correctly means moving these three checks as early as the ingestion edge and making the boundary itself observable, which is the conceptual core of Defining Spatial Data Trust Boundaries. Exactly which signals are eligible to cross the boundary at each tier is constrained by the Observability Scoping Rules for Vector Data.
Implementation
The boundary is built in three layers on the same node: a schema-level validity gate, a quarantine reject path, and role/row-level access enforcement. Treat them as one unit — a validity gate without provenance control still lets a compromised ETL role write technically-valid-but-unauthorized geometry.
Schema-level validity gates
Trust begins at the table definition. Embed the validity and SRID contract directly in DDL so the boundary cannot be bypassed by any writer. For low-volume critical feature classes (cadastral parcels, emergency response zones), enforce it synchronously with CHECK constraints:
-- Critical table: the boundary contract is the table definition itself
CREATE TABLE public.cadastral_parcels (
parcel_id UUID PRIMARY KEY,
geom GEOMETRY(Polygon, 4326), -- typed column rejects wrong geometry type + SRID
ingestion_ts TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT valid_geometry CHECK (ST_IsValid(geom) = TRUE), -- blocks self-intersections
CONSTRAINT correct_srid CHECK (ST_SRID(geom) = 4326) -- blocks silent CRS drift
);
The typed GEOMETRY(Polygon, 4326) column already rejects the wrong geometry type and a mismatched SRID at write time; the two CHECK constraints add the validity guarantee the column type cannot express. This is the same SRID contract enforced in the Coordinate Reference System Validation workflow, applied here as a hard write barrier rather than a freshness check.
Quarantine reject path
A synchronous CHECK constraint aborts the whole transaction on one bad row, which is unacceptable for high-throughput sensor or telemetry feeds. For those, move the boundary into a BEFORE trigger that diverts invalid rows into a quarantine schema while letting the batch proceed, capturing ST_IsValidReason(geom) for triage:
CREATE SCHEMA IF NOT EXISTS quarantine;
CREATE TABLE quarantine.cadastral_parcels (
original_id UUID,
geom GEOMETRY(Polygon, 4326),
failed_reason TEXT, -- human-readable cause from ST_IsValidReason
quarantined_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE OR REPLACE FUNCTION route_to_quarantine() RETURNS TRIGGER AS $$
BEGIN
IF NOT ST_IsValid(NEW.geom) OR ST_SRID(NEW.geom) != 4326 THEN
INSERT INTO quarantine.cadastral_parcels (original_id, geom, failed_reason)
VALUES (NEW.parcel_id, NEW.geom, ST_IsValidReason(NEW.geom));
RETURN NULL; -- RETURN NULL stops the row entering the main table without aborting the batch
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER validate_cadastral_geom
BEFORE INSERT OR UPDATE ON public.cadastral_parcels
FOR EACH ROW EXECUTE FUNCTION route_to_quarantine();
The RETURN NULL is the whole point: it silently drops the offending row from the main table while keeping the rest of the multi-row statement intact, and the quarantine insert preserves the geometry plus its failure reason for a later ST_MakeValid repair pass.
Provenance: role and row-level boundaries
Validity is not authorization. PostGIS functions such as ST_Transform, ST_Buffer, and ST_Intersection are computationally expensive and can be weaponized through unbounded queries, so the boundary must also restrict who writes and which classification of rows they touch. Define minimal roles and a Row-Level Security policy keyed to a per-session classification (see the PostgreSQL Row-Level Security reference):
CREATE ROLE gis_reader NOLOGIN;
CREATE ROLE gis_etl_writer NOLOGIN;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO gis_reader;
GRANT INSERT, UPDATE ON ALL TABLES IN SCHEMA staging TO gis_etl_writer; -- writers land in staging, not public
ALTER TABLE public.spatial_assets ENABLE ROW LEVEL SECURITY;
CREATE POLICY classification_filter ON public.spatial_assets
USING (data_classification = current_setting('app.user_classification', true));
Then bound the cost a writer can impose. Per-role statement_timeout and parallel-worker caps stop a single unbounded ST_Buffer from saturating the node, and full statement logging gives the boundary an audit trail:
ALTER ROLE gis_etl_writer SET log_statement = 'all';
ALTER ROLE gis_etl_writer SET statement_timeout = '30s';
ALTER ROLE gis_etl_writer SET max_parallel_workers_per_gather = 2;
Make the boundary observable
A boundary you cannot see is a boundary you cannot trust. Scrape the quarantine growth rate and slow-spatial-query signal with the OpenTelemetry Collector Contrib sqlquery receiver so the boundary feeds the same namespace defined in the Geospatial Metric Taxonomy for ETL:
# otel-collector-config.yaml — contrib build, runs beside the PostGIS node
receivers:
sqlquery:
driver: postgres
datasource: "host=postgis-primary port=5432 user=otel_monitor password=${PG_OTEL_PASS} sslmode=require"
collection_interval: 30s
queries:
- sql: >
SELECT count(*) AS count
FROM quarantine.cadastral_parcels
WHERE quarantined_at > NOW() - INTERVAL '15 minutes'
metrics:
- metric_name: "gis.spatial.quarantine_growth_rate"
value_column: "count"
value_type: int
- sql: >
SELECT avg(mean_exec_time) AS avg
FROM pg_stat_statements
WHERE query LIKE '%ST_%' AND mean_exec_time > 500
metrics:
- metric_name: "gis.spatial.slow_query_avg_ms"
value_column: "avg"
value_type: double
The wiring for spans and resource attributes that surround these metrics is covered in OpenTelemetry Integration for GIS Pipelines; here the receiver only needs read access to the quarantine table and pg_stat_statements.
Verification & Testing
Confirm the boundary holds before you rely on it. Inject a known self-intersecting polygon and assert it never reaches the trusted table:
-- Synthetic bad geometry: a bow-tie polygon (self-intersection)
INSERT INTO public.cadastral_parcels (parcel_id, geom)
VALUES (
gen_random_uuid(),
ST_GeomFromText('POLYGON((0 0, 1 1, 1 0, 0 1, 0 0))', 4326)
);
-- Expect 0 rows in the trusted table from this batch, 1 in quarantine
SELECT (SELECT count(*) FROM quarantine.cadastral_parcels
WHERE quarantined_at > NOW() - INTERVAL '1 minute') AS quarantined,
failed_reason
FROM quarantine.cadastral_parcels
ORDER BY quarantined_at DESC LIMIT 1;
A correct boundary returns quarantined = 1 with a failed_reason like Self-intersection. Next, verify the alerting threshold actually fires. The boundary is breached when the quarantine growth over a 15-minute window crosses the safe rate ; encode that as a Prometheus rule and validate it against the synthetic load:
groups:
- name: postgis_trust_boundary
rules:
- alert: HighQuarantineVolume
expr: increase(gis_spatial_quarantine_growth_rate[15m]) > 1000
for: 5m
labels: { severity: critical }
annotations:
summary: "Spatial quarantine exceeding safe threshold"
description: "Invalid-geometry ingestion > 1000 rows/15m — verify ETL CRS mapping."
- alert: SpatialFunctionRateLimitExceeded
expr: rate(gis_spatial_function_calls_total[1m]) > 500
for: 2m
labels: { severity: warning }
annotations:
summary: "Spatial function call rate limit breached"
description: "Role rate limit exceeded — check for unbounded ST_Buffer / ST_Intersection."
Finally, confirm the validity gate did not quietly cost you index health — a boundary that forces sequential scans trades one failure mode for another:
SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE indexrelname LIKE '%geom%';
Gotchas & Failure Modes
SRID mismatch passing a validity-only gate. ST_IsValid says nothing about the coordinate system. A geometry can be perfectly valid and in the wrong SRID, so a boundary that checks only ST_IsValid lets CRS drift through. Always pair the validity check with ST_SRID — the typed column plus the correct_srid constraint above closes this, and the patterns in Geometry Validity & Topology Checks cover the topology edge cases the type system cannot.
ST_MakeValid silently rewriting topology. Repairing quarantined rows with ST_MakeValid(geom) can split a polygon into a MultiPolygon or drop slivers, changing area and vertex count. Re-ingest repaired rows through an idempotent INSERT ... ON CONFLICT DO UPDATE and re-assert ST_NPoints and ST_Area deltas against the original before trusting them — do not assume “made valid” means “unchanged.”
RLS bypass through SECURITY DEFINER functions. A spatial helper function declared SECURITY DEFINER executes with the owner’s privileges and ignores the calling role’s RLS policy, quietly punching a hole in the provenance boundary. Audit every SECURITY DEFINER function that touches a row-level-secured table and prefer SECURITY INVOKER unless elevation is deliberate.
When a boundary breach does cascade, degrade rather than fail. The tiered response below mirrors the strategy in Fallback Chains for Spatial API Failures:
| Tier | Trigger | Action |
|---|---|---|
| 1 | Primary node slow / lock contention | Route reads to a replica with warmed spatial indexes |
| 2 | Sustained ST_* latency over budget |
Serve simplified geometry via ST_Simplify(geom, tolerance) |
| 3 | Validation gate degraded | Return ST_Envelope(geom) with an X-Geometry-Simplified: true header |
Frequently Asked Questions
Should I use a synchronous CHECK constraint or a quarantine trigger?
Use synchronous CHECK constraints for low-volume, high-criticality feature classes where a single bad row should abort the transaction (cadastral, emergency zones). Use the quarantine trigger for high-throughput feeds where blocking the whole batch on one invalid geometry is worse than diverting that row for later repair. Many production nodes run both: constraints on trusted tables, quarantine routing on staging.
Why does an invalid geometry still pass my type-checked GEOMETRY column?
A GEOMETRY(Polygon, 4326) column enforces geometry type and SRID, but not validity. A self-intersecting bow-tie is still a polygon in 4326, so it satisfies the column type and only an explicit ST_IsValid constraint or trigger rejects it. Type and validity are independent guarantees and you need both.
How do I keep the trust boundary from triggering sequential scans?
The validity gate runs on write, not read, so it does not directly cause scans — missing or bloated GiST indexes do. After enabling the boundary, watch idx_scan in pg_stat_user_indexes and run EXPLAIN (ANALYZE, BUFFERS) on your hot spatial queries; a sudden drop in index scans usually means an index needs a REINDEX or the planner lost statistics after a bulk quarantine-repair reload.
Where should the boundary live in a multi-region deployment?
At the regional edge, not the central backend. The regional collector is the egress control point that strips high-precision coordinate attributes and enforces label allowlists before telemetry leaves the jurisdiction, as laid out in Monitoring Topology for Multi-Region GIS. Pushing the validity and provenance checks to the edge keeps a partition in one region from exporting unvalidated geometry into another.
My quarantine table is growing but queries look fine — what now?
Rising quarantine volume with healthy query latency almost always means an upstream CRS mapping changed and every incoming row now fails the ST_SRID check. Inspect failed_reason distribution first; if it is dominated by SRID mismatches rather than self-intersections, fix the ETL reprojection step, not the database.
Related
- Monitoring Topology for Multi-Region GIS — the parent topic, where these boundaries become per-region egress control points.
- Defining Spatial Data Trust Boundaries — the conceptual model behind validity, referential, and provenance checks.
- Geospatial Observability Architecture & Fundamentals — the overarching observability discipline this page builds on.