Indexing trajectories in PostGIS for space-time range queries
A trajectory table with a GiST index on geometry and a B-tree on timestamp will still scan far more than it needs, because neither index knows about the other. The configuration that works is declarative partitioning by day or month, a BRIN index on the clustered timestamp inside each partition, GiST on geometry, and query predicates written so the planner can prune partitions before it opens any of them.
Why this happens
PostgreSQL can use only one index efficiently per table scan in the common case, and combining two through a bitmap heap scan costs a bitmap of the whole candidate set. On a trajectory table that is deep in time and shallow in space, the spatial index alone returns every year of history for the queried district, and the timestamp index alone returns every district for the queried hour. Either way the bitmap is enormous before the recheck runs.
Partitioning changes the shape of the problem, because partition pruning happens in the planner rather than during execution. A query bounded to one day touches one partition and never opens the rest, so the indexes inside those partitions are irrelevant. The general argument is in spatiotemporal query indexing; this page is the PostGIS-specific configuration.
Core pipeline
- Partition by time with declarative range partitioning on the UTC timestamp, one partition per day or month.
- Cluster inside the partition by a spatial key so that a BRIN index on the timestamp and the geometry are both correlated with physical order.
- Index per partition — GiST on geometry, BRIN on the timestamp, and a B-tree on entity id only if you query by entity.
- Write predicates the planner can prune on, which means constraining the partition key directly, not only a derived expression.
Production-ready SQL and Python
-- Parent table. The partition key must be part of the primary key in a
-- declaratively partitioned table — this trips up most first attempts.
CREATE TABLE fix (
entity_id text NOT NULL,
t timestamptz NOT NULL,
geom geometry(Point, 32631) NOT NULL, -- projected, metric
speed_ms real,
quality smallint,
PRIMARY KEY (entity_id, t)
) PARTITION BY RANGE (t);
-- One partition per day. Create them ahead of ingestion, not on demand:
-- an INSERT with no matching partition fails rather than creating one.
CREATE TABLE fix_2026_05_14 PARTITION OF fix
FOR VALUES FROM ('2026-05-14 00:00+00') TO ('2026-05-15 00:00+00');
-- GiST for geometry. Only spatial operators (&&, ST_DWithin, ST_Intersects)
-- use it; ST_Distance(...) < x in the WHERE clause does NOT.
CREATE INDEX fix_2026_05_14_geom_gix
ON fix_2026_05_14 USING GIST (geom);
-- BRIN on the timestamp: tiny, and effective only because rows inside the
-- partition are in time order. On unclustered data BRIN is useless.
CREATE INDEX fix_2026_05_14_t_brin
ON fix_2026_05_14 USING BRIN (t) WITH (pages_per_range = 32);
-- Physically order the partition once after load. CLUSTER takes an
-- ACCESS EXCLUSIVE lock, so do it on the day's partition after ingestion
-- completes, never on the live one.
CLUSTER fix_2026_05_14 USING fix_2026_05_14_t_brin;
ANALYZE fix_2026_05_14;
-- The query shape that prunes. Note the partition key appears as a plain
-- range predicate: the planner needs t itself, not a function of t.
EXPLAIN (ANALYZE, BUFFERS)
SELECT entity_id, t, geom
FROM fix
WHERE t >= '2026-05-14 14:00+00'
AND t < '2026-05-14 15:00+00'
AND ST_DWithin(
geom,
ST_SetSRID(ST_MakePoint(587400, 5760200), 32631),
1200 -- metres, because 32631
);
-- Look for "Subplans Removed" or a single partition in the plan. If every
-- partition appears, the predicate is not prunable — the usual cause is
-- date_trunc('hour', t) or t AT TIME ZONE '...' on the left-hand side.
import psycopg
def assert_pruned(conn: psycopg.Connection, sql: str, params: tuple,
max_partitions: int = 2) -> dict:
"""
Run EXPLAIN ANALYZE and assert the planner pruned to a few partitions.
A query that returns correct rows while scanning every partition is the
failure this catches — it is invisible in the results and obvious in the
plan, and it gets slower every day as the archive grows.
Raises
------
AssertionError
If more than max_partitions were scanned, or a sequential scan
appears on a partition.
"""
with conn.cursor() as cur:
cur.execute("EXPLAIN (ANALYZE, FORMAT JSON) " + sql, params)
plan = cur.fetchone()[0][0]["Plan"]
seen, seq_scans = set(), []
def walk(node):
rel = node.get("Relation Name")
if rel and rel.startswith("fix_"):
seen.add(rel)
if node.get("Node Type") == "Seq Scan":
seq_scans.append(rel)
for child in node.get("Plans", []):
walk(child)
walk(plan)
assert len(seen) <= max_partitions, (
f"scanned {len(seen)} partitions {sorted(seen)[:4]}… — the predicate "
"is not prunable; check for a function wrapping the partition key"
)
assert not seq_scans, f"sequential scan on {seq_scans} — missing or unused index"
return {"partitions": sorted(seen), "actual_rows": plan.get("Actual Rows")}
Validation block
def validate_index_health(conn, table: str) -> dict:
"""Correlation is what makes BRIN work; check it rather than assuming it."""
with conn.cursor() as cur:
cur.execute(
"SELECT correlation FROM pg_stats WHERE tablename = %s AND attname = 't'",
(table,),
)
row = cur.fetchone()
corr = float(row[0]) if row and row[0] is not None else 0.0
# Near 1.0 means physical order matches time order and BRIN prunes well.
# Below ~0.9, BRIN is close to useless and a B-tree is the better trade.
assert abs(corr) > 0.9, (
f"{table}: t correlation {corr:.2f} — re-CLUSTER after load, or the "
"BRIN index is costing space and saving nothing"
)
return {"table": table, "t_correlation": corr}
ANALYZE.Common mistakes and gotchas
-
Wrapping the partition key in a function.
WHERE date_trunc('hour', t) = …cannot prune. Always constraintitself with a range and add the derived expression separately if you need it. -
Using
ST_Distance(...) < rinstead ofST_DWithin. Only the second is index-assisted. The first computes a distance for every candidate row. -
Mixing SRIDs. A query point in 4326 against geometry in 32631 either errors or, worse, silently returns nothing after an implicit cast. Store one CRS per column and assert it — see CRS transformation best practices.
-
Adding BRIN without clustering. The index is created, the plan uses it, and it prunes nothing. Check
pg_stats.correlationrather than assuming. -
Creating partitions on demand. An insert with no matching partition raises. Create them ahead, on a schedule, and monitor the runway.
-
Forgetting
ANALYZEafter a bulk load. The planner then estimates from stale statistics and often chooses a sequential scan over a perfectly good index.
FAQ
Daily or monthly partitions?
Aim for partitions between 100 MB and a few GB. At city scale and 1 Hz that is roughly daily; for a small fleet monthly is better, because thousands of tiny partitions make planning itself slow. Watch planning time in EXPLAIN ANALYZE — when it becomes a noticeable fraction of execution time, the partitions are too small.
Should the geometry be a point per fix or a line per trip?
Keep points as the base table and maintain a trip-level table with a LineString and a bounding box beside it. The trip table is small enough to index and query first, which turns most questions into “which trips could match?” followed by a targeted read of the fix table — the same funnel described in spatiotemporal query indexing.
Is PostGIS the right choice at all?
If the archive is append-only and read by analysts, a partitioned GeoParquet lake queried with DuckDB is usually faster and cheaper. PostGIS earns its place when the same data is updated, when many clients query concurrently, or when transactional guarantees matter alongside the spatial operators.
Related
- Spatiotemporal Query Indexing — the parent reference and the pruning funnel.
- Using STR-Packed R-Trees for Fast Trajectory Lookups — the in-process alternative for static candidate sets.
- Spatial Storage Formats — the file-based path this page’s database approach competes with.
- Optimizing Spatial Joins for Trajectory-to-Zone Matching — the same pruning logic outside a database.
- Discrete Global Grid Systems — cell ids as an alternative clustering key inside each partition.