Spatial Indexing and Partitioning Yield Points in PostGIS
One-sentence answer: partition telemetry by season, carry the season as a column so the planner can prune without a join, build one GiST index per partition after loading, and always include the season in the predicate.
Context
A 400-field operation running planting, application and harvest generates tens of millions of telemetry rows a season. After three seasons the table is past a hundred million rows, and the query that used to answer in 40 ms — “every sample for this field-season” — starts taking several seconds, because the index no longer fits comfortably in cache and every lookup touches disk.
Partitioning fixes it, but only for the queries you designed it for. That is the trade this guide is about: naming the access patterns first, then choosing a key that serves them, and verifying with EXPLAIN that the planner is doing what you think. It implements the telemetry table from PostGIS schema design for farm data.
Prerequisites
Beyond the parent topic’s stack: PostgreSQL 16+ with enable_partition_pruning on (the default), and enough disk headroom to hold an index build alongside the table.
Step-by-step
1. Write down the access patterns. For farm telemetry they are, in order of frequency: all samples for one field-season; all samples for one operation; samples within a polygon for one season; and a season-wide aggregate per field. Every one of them carries a season — which is what justifies the key.
2. Partition by season, and carry season on the row so no join is needed for the planner to prune.
3. Load, then index.
4. Write pruning-friendly predicates.
5. Verify with EXPLAIN (ANALYZE, BUFFERS).
-- 1. Parent table, partitioned by list on season.
CREATE TABLE telemetry_point (
operation_id bigint NOT NULL REFERENCES operation(operation_id) ON DELETE CASCADE,
season smallint NOT NULL,
ts timestamptz NOT NULL,
geom geometry(Point, 4326) NOT NULL,
yield_dry double precision,
speed_m_s real,
width_m real,
PRIMARY KEY (season, operation_id, ts)
) PARTITION BY LIST (season);
-- 2. One partition per season. Create next season's before harvest, not during it.
CREATE TABLE telemetry_point_2026 PARTITION OF telemetry_point FOR VALUES IN (2026);
-- 3. Load first (COPY into the partition directly), then index.
CREATE INDEX telemetry_2026_geom_gix ON telemetry_point_2026 USING gist (geom)
WITH (fillfactor = 95);
CREATE INDEX telemetry_2026_op_ix ON telemetry_point_2026 (operation_id, ts);
ANALYZE telemetry_point_2026;
-- 4. Pruning-friendly: the season is in the predicate, so three partitions are skipped.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*), avg(yield_dry)
FROM telemetry_point t
JOIN operation o USING (operation_id)
WHERE t.season = 2026
AND o.field_season_id = 918234;
-- The plan must contain exactly one "Index Scan ... telemetry_point_2026" and no other partition.
import re
from sqlalchemy import create_engine, text
def assert_pruned(dsn: str, sql: str, expect_partitions: int = 1) -> str:
"""Run EXPLAIN and assert the planner touched only the expected number of partitions."""
engine = create_engine(dsn, future=True)
with engine.connect() as conn:
plan = "\n".join(r[0] for r in conn.execute(text("EXPLAIN (ANALYZE, BUFFERS) " + sql)))
touched = set(re.findall(r"telemetry_point_(\d{4})", plan))
assert len(touched) == expect_partitions, (
f"planner touched {sorted(touched)} — expected {expect_partitions} partition(s). "
"Is the season in the predicate, or is it only reachable through a join?")
assert "Seq Scan" not in plan, f"sequential scan in plan:\n{plan}"
return plan
Inline verification — measure the thing you actually care about, then prove why it is fast:
import time
sql = """
SELECT count(*) FROM telemetry_point
WHERE season = 2026 AND operation_id = 55123
"""
start = time.perf_counter()
plan = assert_pruned(DSN, sql, expect_partitions=1)
print(f"{time.perf_counter() - start:.3f}s\n{plan.splitlines()[0]}")
assert "Index Scan" in plan or "Bitmap" in plan, "the index is not being used"
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
| Partition key | season |
Chosen because every listed access pattern carries it. A key that only some queries filter on gives those queries a speed-up and makes every other query slower |
| Partition granularity | one per season | Four to ten partitions is the comfortable range. Monthly partitioning turns a decade into 120 relations and adds planning time to every query for no gain |
| GiST fill factor | 95 on closed seasons | Telemetry partitions are append-only once the season ends, so a tight index packs more per page; leave the default on the live partition |
work_mem |
64–256 MB per session | Large ST_Intersects joins and zone aggregations spill to disk below this. Raise it for the analytical session rather than globally, where hundreds of connections would each claim it |
| Index build timing | after the bulk load | Building on a populated partition is several times faster than maintaining it row by row, and produces a better-balanced tree |
ANALYZE |
after every bulk load | Statistics from an empty relation produce a plan built for one, which is how a freshly loaded partition turns into a sequential scan |
Create next season’s partition before harvest rather than during it. A row whose season has no partition is rejected outright, and discovering that at 21:00 on the first day of harvest is a bad way to learn about declarative partitioning.
Gotchas and edge cases
- A season reachable only through a join cannot prune.
JOIN field_season fs ON … WHERE fs.season = 2026gives the planner no constant on the partition key at plan time. That is exactly whyseasonis denormalised onto the row: it is the one column duplicated deliberately, so the predicate can be written directly.
-
Indexes on the parent are propagated, but per-partition tuning is not.
CREATE INDEX ON telemetry_point …creates one on every partition, which is convenient and prevents you from using a different fill factor on the frozen partitions than on the live one. For an append-only history, a high fill factor on closed seasons is free space savings. -
Too many partitions costs planning time. Partitioning by month instead of season turns four partitions into forty-eight and adds milliseconds of planning to every query, for no gain — nothing in the access-pattern list filters by month alone.
-
ST_DWithinbeatsST_Distancefor proximity.ST_Distance(geom, p) < 30cannot use the index because the function must be evaluated per row;ST_DWithin(geom, p, 30)is index-aware and does the same job. This is the single most common reason a GiST index appears to be ignored. -
Mixed SRIDs silently return nothing. A predicate comparing an EPSG:4326 column with a UTM geometry produces zero rows and no error — the same class of failure catalogued in debugging CRS and projection errors. Transform explicitly in the query, and keep one canonical SRID in storage.
-
ANALYZEafter a bulk load, always. A freshly loaded partition with stale statistics gets a plan built for an empty table, which usually means a sequential scan over eleven million rows. It costs seconds and saves hours.
This guide is part of PostGIS Schema Design for Farm Data — see there for the entities, constraints and manifest that surround this table.
Related
- Loading GeoDataFrames into PostGIS with GeoAlchemy2 — getting the rows in before the index is built
- Yield Monitor Data Cleaning & Telemetry QA — the queries this layout is tuned for
- PostGIS Schema Design for Farm Data — the schema this table belongs to