Part of Advanced GiST Indexing & Optimization, this technique narrows a GiST index to the rows your application actually queries — by attaching an immutable WHERE predicate at index creation time. Instead of indexing every geometry in a table, a partial GiST index covers only the active, regionally relevant, or tenant-scoped subset. The result is a smaller index that fits more readily in shared_buffers, produces fewer buffer reads per query, and generates less vacuum pressure as rows outside the predicate are updated.
When your application consistently filters on a stable, low-volatility column — tenant ID, status flag, region code — a partial index is often the highest-leverage change you can make. It also pairs naturally with index-only scan strategies and composite spatial indexes when access patterns warrant both.
Prerequisites and Infrastructure Validation
Before creating a partial GiST index, confirm your environment meets these requirements.
PostgreSQL and PostGIS versions:
-- Verify PostGIS is installed and at a supported version
SELECT extname, extversion
FROM pg_extension
WHERE extname IN ('postgis', 'postgis_topology');
-- Expect: postgis 3.2+ and, if topology is used, postgis_topology 3.2+
-- Confirm PostgreSQL version (14+ recommended for improved partial predicate evaluation)
SELECT version();Python package baseline:
psycopg2-binary >= 2.9orpsycopg >= 3.1for async-capable connectionsSQLAlchemy >= 2.0andGeoAlchemy2 >= 0.14for ORM-based spatial column mappingshapely >= 2.0if you need client-side geometry manipulation before binding parameters
SRID consistency check: queries that mix SRIDs force implicit ST_Transform calls, defeating predicate matching. Verify that your geometry column carries a declared SRID:
-- Confirm SRID on the target column
SELECT f_table_schema, f_table_name, f_geometry_column, srid, type
FROM geometry_columns
WHERE f_table_name = 'assets';
-- Expected: srid = 4326 (or your projected CRS), not 0GiST index existence check:
-- Confirm a baseline GiST index is present (or confirm none exists yet)
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'assets'
AND indexdef ILIKE '%using gist%';Core Execution Workflow
Step 1 — Audit Query Logs for Stable Filter Predicates
Partial indexes only accelerate queries whose WHERE clauses are logically implied by the index predicate. Start by finding the highest-cost spatial queries with consistent non-spatial filters:
-- Identify frequent spatial queries with stable non-spatial predicates
SELECT
query,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((mean_exec_time * calls)::numeric, 0) AS total_ms,
rows
FROM pg_stat_statements
WHERE query ILIKE '%ST_Intersects%'
OR query ILIKE '%ST_DWithin%'
OR query ILIKE '% && %'
ORDER BY mean_exec_time * calls DESC
LIMIT 20;Look for deterministic filter conditions that appear in most or all of the results: status = 'active', tenant_id = 42, region_code = 'EU-CENTRAL'. These are candidates for the index predicate.
Immutability requirement: the predicate must contain only immutable expressions. Volatile functions like NOW() or CURRENT_TIMESTAMP are rejected by PostgreSQL at CREATE INDEX time. For time-windowed data, use a fixed timestamptz literal: valid_to > '2026-01-01'::timestamptz. Rotate this index periodically via a scheduled maintenance script.
A partial index yields measurable gains when the predicate filters out at least 30–40 % of the table. If your filter is very broad (more than 70 % of rows pass), a full GiST index will perform comparably; see the query plan analysis guide for cost estimation techniques to decide which applies.
Step 2 — Define and Create the Partial Index
-- Production partial GiST index — created CONCURRENTLY to avoid table locks
CREATE INDEX CONCURRENTLY idx_assets_active_geom
ON public.assets
USING GIST (geom)
WHERE status = 'active'
AND tenant_id = 42;
-- For multi-tenant schemas, create per-tenant or per-region indexes
-- when a single high-traffic tenant dominates query load
CREATE INDEX CONCURRENTLY idx_assets_eu_central_geom
ON public.assets
USING GIST (geom)
WHERE status = 'active'
AND region_code = 'EU-CENTRAL';Key rules during DDL:
CREATE INDEX CONCURRENTLYprevents access-exclusive locks in production. It takes longer to build but does not block reads or writes.- Never parameterize the
WHEREclause with application variables — the predicate must be a static expression tree stored inpg_index.indpred. - If you need both categorical and spatial selectivity, compare the selectivity benefit of a partial index against that of composite spatial indexes before committing to either.
- For active map region workflows where spatial bounds define the predicate, see creating partial indexes for active map regions.
Step 3 — Align Python Query Generation with the Predicate
The planner uses the partial index only when it can prove that the query’s WHERE clause implies the index predicate. Python ORMs and raw query builders must reproduce the exact filter expression.
SQLAlchemy 2.0 with GeoAlchemy2:
from sqlalchemy import select, and_
from sqlalchemy.orm import Session, DeclarativeBase, mapped_column
from geoalchemy2 import Geometry
from geoalchemy2.functions import ST_Intersects, ST_GeomFromText
from sqlalchemy import Integer, String
class Base(DeclarativeBase):
pass
class Asset(Base):
__tablename__ = "assets"
id: int = mapped_column(Integer, primary_key=True)
status: str = mapped_column(String)
tenant_id: int = mapped_column(Integer)
geom = mapped_column(Geometry("GEOMETRY", srid=4326))
def fetch_active_assets_in_bbox(
session: Session,
tenant_id: int,
bbox_wkt: str,
) -> list[Asset]:
# status and tenant_id predicates must match the partial index definition exactly
stmt = (
select(Asset)
.where(
Asset.status == "active", # must match index predicate literal
Asset.tenant_id == tenant_id, # matched by PostgreSQL for this tenant
ST_Intersects(
Asset.geom,
ST_GeomFromText(bbox_wkt, 4326),
),
)
)
return list(session.scalars(stmt))psycopg3 parameterized execution:
import psycopg
from psycopg.rows import dict_row
def query_active_geometries(
conn: psycopg.Connection,
tenant_id: int,
bbox_wkt: str,
) -> list[dict]:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, name, ST_AsText(geom) AS geom_wkt
FROM public.assets
WHERE status = %s -- must be the literal 'active' to match predicate
AND tenant_id = %s
AND geom && ST_GeomFromText(%s, 4326)
""",
("active", tenant_id, bbox_wkt),
)
return cur.fetchall()Critical alignment rules:
- Do not wrap static predicates in
COALESCE(),CASE, or dynamicORchains — the planner cannot prove implication through those constructs. - If your ORM adds an automatic
ORDER BY idorLIMIT, verify viaEXPLAINthat the sort does not push the planner to a sequential scan. Use anINCLUDEclause or a covering index for sort keys. - If you use soft deletes (
deleted_at IS NULL), include that exact expression in the partial index predicate and reproduce it in every query. - For
ST_DWithinradius searches, pair the partial index predicate with the ST_DWithin radius search patterns to ensure the operator can exploit the index.
Step 4 — Validate with EXPLAIN (ANALYZE, BUFFERS)
After deployment, parse the query plan programmatically rather than reading it by eye. This catches planner regressions during CI/CD or after schema migrations.
import json
import psycopg
def verify_partial_index_usage(
conn: psycopg.Connection,
query: str,
params: tuple = (),
expected_index: str = "idx_assets_active_geom",
) -> dict:
explain_sql = f"EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {query}"
with conn.cursor() as cur:
cur.execute(explain_sql, params)
plan = cur.fetchone()[0] # returns list[dict] in JSON mode
def collect_index_scans(node: dict, acc: list) -> None:
if "Index Name" in node:
acc.append({
"index": node["Index Name"],
"node_type": node.get("Node Type"),
"actual_rows": node.get("Actual Rows"),
"recheck_removed": node.get("Rows Removed by Index Recheck", 0),
})
for child in node.get("Plans", []):
collect_index_scans(child, acc)
scans: list[dict] = []
collect_index_scans(plan[0]["Plan"], scans)
return {
"index_used": any(s["index"] == expected_index for s in scans),
"scans": scans,
"planning_time_ms": plan[0].get("Planning Time"),
"execution_time_ms": plan[0].get("Execution Time"),
}Performance Considerations
Expected EXPLAIN Output
A well-tuned partial GiST index should produce an Index Scan or Bitmap Index Scan node with low actual row counts at the index level. A representative plan:
Bitmap Heap Scan on assets (cost=4.12..38.50 rows=12 width=72)
(actual time=0.231..0.487 rows=9 loops=1)
Recheck Cond: (geom && '...'::geometry)
Heap Blocks: exact=7
Buffers: shared hit=11
-> Bitmap Index Scan on idx_assets_active_geom
(cost=0.00..4.12 rows=12 width=0)
(actual time=0.198..0.199 rows=9 loops=1)
Index Cond: (geom && '...'::geometry)
Buffers: shared hit=11 with no shared read blocks confirms the index lives in shared_buffers. If you see large shared read counts, consider raising shared_buffers or pre-warming the index with pg_prewarm.
Relevant GUC Settings
| Parameter | Recommended value | Effect on partial index usage |
|---|---|---|
random_page_cost |
1.1 (SSD), 4.0 (HDD) |
Too-high values cause the planner to prefer sequential scans; lower it on SSD storage |
effective_cache_size |
50–75 % of RAM | Higher values tell the planner more data fits in OS cache, favouring index paths |
work_mem |
16MB–64MB per query |
Affects sort and hash steps downstream of an index scan |
enable_indexscan |
on |
Confirm not accidentally disabled in session variables |
-- Verify GUC values for the current session
SHOW random_page_cost;
SHOW effective_cache_size;
-- Update at the table level if you cannot change postgresql.conf
ALTER TABLE public.assets SET (autovacuum_analyze_scale_factor = 0.02);Statistics Freshness
The planner’s cost estimates depend on pg_statistic. After bulk data loads or significant row churn, stale statistics cause the planner to misestimate selectivity and may trigger sequential scans:
-- Refresh statistics on the target table after bulk operations
ANALYZE public.assets;
-- For very large tables, analyse only the relevant column to save time
ANALYZE public.assets (geom, status, tenant_id);Common Failure Modes and Fixes
Planner Chooses a Sequential Scan
Diagnosis:
-- Check whether the partial index is being considered
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, ST_AsText(geom)
FROM public.assets
WHERE status = 'active'
AND tenant_id = 42
AND geom && ST_GeomFromText(
'POLYGON((10 45, 11 45, 11 46, 10 46, 10 45))', 4326
);If the plan shows Seq Scan on assets, check:
- Statistics age: run
ANALYZE public.assets;. random_page_cost: lower it for SSD (set1.1).- Predicate alignment: does your query
WHEREclause exactly imply the index predicate? A query withstatus IN ('active', 'pending')does not implystatus = 'active'. - Row count estimate: if
Rows=1shows up in the index node butActual Rows=5000, the histogram is stale — runANALYZEwith a higherdefault_statistics_target:
ALTER TABLE public.assets ALTER COLUMN status SET STATISTICS 200;
ANALYZE public.assets (status);SRID Mismatch Forces ST_Transform
If the bounding box literal you pass has a different SRID than the indexed column, PostGIS inserts an implicit ST_Transform, which the planner cannot push inside the index scan. Diagnosis:
-- Check the SRID of the geometry you are passing
SELECT ST_SRID(ST_GeomFromText('POLYGON((...))', 0)); -- SRID 0 = unknown
-- Force the correct SRID in your query
SELECT ST_SRID(geom) FROM public.assets LIMIT 1;
-- Then use that SRID in your application queriesFix: always construct geometry literals with the explicit SRID matching the column — ST_GeomFromText(..., 4326) — and never rely on implicit casting.
Index Not Rebuilt After Predicate Change
Adding a new status value such as 'pending' to your application without rebuilding the index leaves all 'pending' rows unindexed. Queries that filter status IN ('active', 'pending') fall back to a sequential scan.
Fix: when the predicate semantics change, create a replacement index CONCURRENTLY, verify planner adoption via EXPLAIN, then drop the old index:
-- Create replacement index for expanded predicate
CREATE INDEX CONCURRENTLY idx_assets_active_pending_geom
ON public.assets
USING GIST (geom)
WHERE status IN ('active', 'pending')
AND tenant_id = 42;
-- After verifying the planner uses the new index, drop the old one
DROP INDEX CONCURRENTLY idx_assets_active_geom;OOM on Large Spatial Result Sets
A well-tuned partial index accelerates the scan but does not prevent large result sets from exhausting memory in Python. Use server-side cursors for streaming:
import psycopg
from psycopg.rows import dict_row
def stream_active_geometries(
conn: psycopg.Connection,
tenant_id: int,
bbox_wkt: str,
batch_size: int = 500,
):
with conn.cursor(row_factory=dict_row) as cur:
cur.itersize = batch_size # server-side cursor chunk size
cur.execute(
"""
SELECT id, name, ST_AsText(geom) AS geom_wkt
FROM public.assets
WHERE status = %s
AND tenant_id = %s
AND geom && ST_GeomFromText(%s, 4326)
""",
("active", tenant_id, bbox_wkt),
)
for row in cur: # yields one batch at a time, not all rows at once
yield rowMaintenance and Lifecycle
Automated Rotation for Time-Based Predicates
Rolling-window predicates require periodic index rotation because NOW() cannot appear in an index predicate. Use a maintenance script scheduled via pg_cron or an external scheduler:
-- Step 1: create the next-window index CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_assets_active_2026q3
ON public.assets
USING GIST (geom)
WHERE status = 'active'
AND created_at >= '2026-07-01'::timestamptz
AND created_at < '2026-10-01'::timestamptz;
-- Step 2: after verifying planner adoption, retire the previous window
DROP INDEX CONCURRENTLY idx_assets_active_2026q2;Detecting Dead Indexes
-- Find partial GiST indexes with zero or near-zero usage since last stats reset
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
JOIN pg_indexes USING (schemaname, tablename, indexname)
WHERE indexdef ILIKE '%using gist%'
AND indexdef ILIKE '%where%' -- partial indexes only
ORDER BY idx_scan ASC;Indexes with idx_scan = 0 and non-trivial size are candidates for removal. Before dropping, force a planner check:
-- Confirm the index is genuinely unused, not just cold after a stats reset
SELECT pg_stat_reset_single_table_counters('public.assets'::regclass::oid);
-- Wait for one full traffic cycle (e.g. 24 h), then re-query pg_stat_user_indexesAnti-Patterns to Avoid
- Predicate drift: changing
status = 'active'tostatus IN ('active', 'pending')in application code without rebuilding the index. The old index silently becomes dead weight. - Volatile predicates: wrapping the time filter in
CASE WHEN ... THEN NOW() ...— PostgreSQL rejects theCREATE INDEXstatement outright. - Over-indexing: creating dozens of partial indexes for low-cardinality combinations. Monitor
pg_statio_user_indexes.idx_tup_readand retire unused entries aggressively. - Skipping VACUUM: partial indexes accumulate dead tuples just like full indexes. Configure
autovacuum_vacuum_thresholdaggressively for high-churn tables, or schedule explicitVACUUM (ANALYZE)during maintenance windows. - ORM abstraction leakage: frameworks that auto-append
ORDER BY primary_keycan cause the planner to abandon the spatial index in favour of a sort-compatible path. UseEXPLAINto detect this and add an explicit sort index or restructure the query.
Verification
After deploying a partial GiST index, confirm it is working with this three-step check:
-- 1. Confirm the index appears in pg_indexes with its predicate
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'assets'
AND indexname = 'idx_assets_active_geom';
-- 2. Run EXPLAIN to verify Index Scan (not Seq Scan)
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id
FROM public.assets
WHERE status = 'active'
AND tenant_id = 42
AND geom && ST_GeomFromText(
'POLYGON((10.0 45.0, 11.0 45.0, 11.0 46.0, 10.0 46.0, 10.0 45.0))',
4326
);
-- 3. Check idx_scan counter increments after a few query runs
SELECT indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
WHERE indexname = 'idx_assets_active_geom';A successful deployment shows Bitmap Index Scan or Index Scan in step 2 and a rising idx_scan count in step 3. If idx_scan stays at zero after real query traffic, re-examine predicate alignment and run ANALYZE.
FAQ: Can I use NOW() in a partial GiST index predicate?
No. PostgreSQL requires every expression in an index predicate to be immutable — its value must not change between calls. NOW(), CURRENT_TIMESTAMP, and RANDOM() are all volatile. Use a hard-coded timestamptz literal ('2026-01-01'::timestamptz) and rotate the index on a schedule.
FAQ: When will the planner choose a sequential scan over the partial index?
The planner falls back to a sequential scan when: (1) statistics are stale and it underestimates selectivity, (2) random_page_cost is set too high relative to seq_page_cost for SSD storage, or (3) the query predicate does not logically imply the index predicate. Run ANALYZE and verify predicate alignment first, then adjust random_page_cost if needed.
FAQ: How do partial GiST indexes differ from composite spatial indexes?
A partial index restricts which rows are indexed by attaching a WHERE clause, shrinking the index to a subset of the table. A composite spatial index extends which columns are indexed, covering both a geometry and a scalar column in a single index entry. They solve different problems and can be combined: a partial composite index restricts rows and covers multiple columns simultaneously.
Related Topics
- Advanced GiST Indexing & Optimization — parent section covering the full GiST index toolkit
- Creating Partial Indexes for Active Map Regions — applying spatial bounds as the index predicate
- Composite Spatial Indexes — combining geometry and scalar columns in a single index
- Index-Only Scan Strategies — eliminating heap fetches by including scalar columns in the index
- Reading EXPLAIN ANALYZE Output for Spatial Joins — interpreting plan nodes to validate index adoption