Tuning GiST and BRIN Indexes for Lineage
Spatial-temporal audit queries filter a lineage table by both footprint and time, and the cheapest plan usually pairs a GiST geometry index with a BRIN timestamp index — but you only know the pairing works, and which fillfactor and pages_per_range to pick, by measuring it with EXPLAIN ANALYZE. This how-to builds both indexes on a lineage table, compares plans before and after, and tunes the parameters, extending the spatial index tuning for provenance queries overview into a concrete measurement exercise.
Prerequisites
- PostgreSQL 15+ with PostGIS 3.4+ and the
postgisextension enabled. - A
lineage_audittable withgeom geometry(Geometry, 4326),valid_from timestamptz, and enough rows (hundreds of thousands or more) for index choice to matter — the column layout from PostGIS lineage schema design works directly. - Rows inserted in
valid_fromorder, or the ability to runCLUSTER, since BRIN depends on physical ordering. - Permission to run
CREATE INDEX,CLUSTER,ANALYZE, andEXPLAIN ANALYZE.
Implementation
Run the target query once to capture the baseline plan, build the two indexes with tuned parameters, refresh statistics, and re-check. The block below does all of it in sequence; execute it statement by statement so you can read each plan.
-- 1. Baseline: expect a Seq Scan with high actual rows and buffer reads.
EXPLAIN (ANALYZE, BUFFERS)
SELECT event_id, dataset_uuid, valid_from
FROM lineage_audit
WHERE geom && ST_MakeEnvelope(-71.2, 42.2, -70.9, 42.5, 4326)
AND valid_from >= '2026-01-01' AND valid_from < '2026-04-01';
-- 2. Check the physical correlation of valid_from BEFORE trusting BRIN.
-- Values near 1.0 (or -1.0) mean rows are physically ordered by time.
SELECT correlation
FROM pg_stats
WHERE tablename = 'lineage_audit' AND attname = 'valid_from';
-- 3. If correlation is weak, force physical order once via a B-tree + CLUSTER.
CREATE INDEX IF NOT EXISTS idx_lineage_ts_btree
ON lineage_audit (valid_from);
CLUSTER lineage_audit USING idx_lineage_ts_btree;
-- 4. Build the GiST spatial index. fillfactor 90 leaves room on leaf pages;
-- for an append-only, rarely-updated audit table you can pack tighter.
CREATE INDEX idx_lineage_geom_gist
ON lineage_audit USING gist (geom) WITH (fillfactor = 95);
-- 5. Build the BRIN temporal index. Smaller pages_per_range = tighter pruning
-- at the cost of a marginally larger index. 32 suits selective date ranges.
CREATE INDEX idx_lineage_ts_brin
ON lineage_audit USING brin (valid_from) WITH (pages_per_range = 32);
-- 6. Refresh statistics so the planner will cost the new indexes correctly.
ANALYZE lineage_audit;
-- 7. Re-run the exact baseline query and compare the plan.
EXPLAIN (ANALYZE, BUFFERS)
SELECT event_id, dataset_uuid, valid_from
FROM lineage_audit
WHERE geom && ST_MakeEnvelope(-71.2, 42.2, -70.9, 42.5, 4326)
AND valid_from >= '2026-01-01' AND valid_from < '2026-04-01';
Two parameter choices drive the outcome. fillfactor = 95 on the GiST index packs leaf pages more densely because an immutable audit table sees few in-place updates, so reserving free space would only waste it. pages_per_range = 32 on the BRIN index makes each summarized block range narrower, which tightens min/max pruning for selective quarter-long date filters; widen it toward 128 if your ranges span years and you want the smallest possible index.
Verification
The proof is in the plan diff. A correctly tuned pair replaces the baseline Seq Scan with a BitmapAnd that combines both indexes:
Bitmap Heap Scan on lineage_audit
Recheck Cond: ((geom && ...) AND (valid_from >= ... AND valid_from < ...))
-> BitmapAnd
-> Bitmap Index Scan on idx_lineage_geom_gist
Index Cond: (geom && ...)
-> Bitmap Index Scan on idx_lineage_ts_brin
Index Cond: (valid_from >= ... AND valid_from < ...)
Compare the actual time and Buffers: shared read figures between the two EXPLAIN ANALYZE runs. A successful tune shows a large drop in both — the spatial and temporal prefilters together discard most of the table before any heap page is touched. If only one index appears in the plan, the other filter is either non-selective for this query or its statistics are stale; re-run ANALYZE and confirm the query uses && and a range predicate the indexes support.
Gotchas & edge cases
- BRIN needs physical ordering. BRIN summarizes each block’s min/max, so if rows are scattered by time across the heap, every range overlaps your filter and the index reads the whole table. Check
pg_stats.correlationfirst; if it is far from ±1,CLUSTERon a timestamp B-tree once to reorder the heap, as shown in step 3. Note thatCLUSTERtakes an exclusive lock and does not maintain order for future inserts — append-only ingestion in time order preserves it naturally. - GiST fillfactor cuts both ways. Packing to 95 shrinks the index and speeds scans on a static table, but if you ever backfill or update geometries heavily, dense pages cause splits and bloat. Keep the default 90 for tables that still receive corrections.
- BRIN summaries go stale on new blocks. Rows inserted after the index is built land in unsummarized ranges until autovacuum or a manual
brin_summarize_new_values('idx_lineage_ts_brin')runs, so a freshly loaded partition may temporarily fall back to scanning. Schedule summarization after bulk loads.