Problem Statement
A property-listings platform operates in fourteen national markets from one PostGIS database. Every query in the product is scoped to a market — a user in Portugal never sees Norwegian listings — but the single GiST index spans all fourteen, so every search descends a tree fourteen times larger than it needs to be. This page applies spatial partitioning on a region key rather than a time key, and shows how to keep the key honest when it has to be derived from geometry.
Why the Naive Approach Fails
The obvious attempt is PARTITION BY RANGE (geom), which PostgreSQL rejects outright: geometry has no default btree operator class, so it cannot be a partition key. The next attempt — a region text column filled in by the application — works mechanically but fails in a subtler way: nothing stops a Norwegian polygon being labelled PT, and once one exists, every query for Portugal returns a listing in the Arctic and pruning has actively hidden the bug.
Production-Ready Implementation
Start with the parent and a partition per market, each carrying a bounding-box constraint that ties the key to the geography:
CREATE TABLE listings (
id bigserial,
region char(2) NOT NULL,
title text NOT NULL,
price_cents bigint NOT NULL,
geom geometry(Point, 4326) NOT NULL,
PRIMARY KEY (id, region)
) PARTITION BY LIST (region);
CREATE TABLE listings_pt PARTITION OF listings
FOR VALUES IN ('PT');
ALTER TABLE listings_pt
ADD CONSTRAINT listings_pt_extent
CHECK (geom && ST_MakeEnvelope(-9.6, 36.9, -6.1, 42.2, 4326));
CREATE TABLE listings_no PARTITION OF listings
FOR VALUES IN ('NO');
ALTER TABLE listings_no
ADD CONSTRAINT listings_no_extent
CHECK (geom && ST_MakeEnvelope(4.0, 57.9, 31.6, 71.4, 4326));
CREATE TABLE listings_default PARTITION OF listings DEFAULT;The extent constraint is cheap — a bounding-box test against a constant — and it turns a data-integrity problem into an insert error at the point of the mistake. It also gives the planner an extra pruning opportunity: a query with a bounding box entirely outside Portugal can skip listings_pt even without the region code, because the constraint proves no row there can match.
Each partition then gets its own spatial index, attached to a parent template:
CREATE INDEX CONCURRENTLY listings_pt_geom_idx ON listings_pt USING gist (geom);
CREATE INDEX CONCURRENTLY listings_no_geom_idx ON listings_no USING gist (geom);
CREATE INDEX listings_geom_idx ON ONLY listings USING gist (geom);
ALTER INDEX listings_geom_idx ATTACH PARTITION listings_pt_geom_idx;
ALTER INDEX listings_geom_idx ATTACH PARTITION listings_no_geom_idx;Deriving the key from the geometry
Where the region can be computed from coordinates alone, a generated column removes the possibility of disagreement entirely:
-- A coarse grid cell as the partition key: immutable, so a generated column works.
ALTER TABLE observations
ADD COLUMN grid_cell text
GENERATED ALWAYS AS (ST_GeoHash(geom, 3)) STORED;ST_GeoHash(geom, 3) yields a cell roughly 150 km across, which makes a natural partition granularity for continental data. Because the column is generated, it cannot drift from the geometry: updating geom updates the key, and PostgreSQL moves the row to the correct partition automatically.
A lookup-based key — “which administrative region contains this point?” — cannot be a generated column, because the answer depends on another table. In that case compute it in the insert path and let the extent constraint catch mistakes:
from sqlalchemy import select, func
def region_for(session, point_wkt: str, srid: int = 4326) -> str:
"""Resolve the market a listing belongs to, once, at insert time."""
stmt = (
select(Region.code)
.where(func.ST_Contains(
Region.geom, func.ST_GeomFromText(point_wkt, srid)))
.order_by(Region.priority)
.limit(1)
)
code = session.scalar(stmt)
if code is None:
raise ValueError(f"no region contains {point_wkt}")
return codeConfiguration and Tuning Knobs
Partition count follows market count, which keeps it naturally small — fourteen partitions is nothing to the planner. If you partition on geohash cells instead, choose the precision deliberately: precision 2 gives about thirty cells over Europe, precision 3 about a thousand. Prefer the coarser one.
Uneven partitions are fine and expected. A German partition holding twelve million listings and a Luxembourgish one holding nine thousand coexist happily; each gets an index sized to its own contents, which is the entire point.
The DEFAULT partition should be monitored, not relied on. Rows landing there mean a new market went live without a partition, and until one is created every query for that market scans the default.
Cross-region queries still work — they just cost more
A list-partitioned table has not become several databases. A query without a region predicate still returns the right answer; it simply searches every partition. That is exactly right for the rare admin report that spans markets, and exactly wrong for a per-request code path, so the distinction worth enforcing is which layer is allowed to omit the key.
The practical pattern is a repository method that requires the region and a separate, explicitly named one that does not:
def search_in_market(session, region: str, bbox) -> list[Listing]:
"""The normal path. The region key makes pruning possible."""
return session.scalars(
select(Listing)
.where(Listing.region == region)
.where(Listing.geom.op("&&")(bbox))
).all()
def search_all_markets(session, bbox) -> list[Listing]:
"""Admin and reporting only — scans every partition by design."""
return session.scalars(
select(Listing).where(Listing.geom.op("&&")(bbox))
).all()Two functions rather than an optional argument, because an optional argument left unset is invisible in a code review while a call to search_all_markets from a request handler is not.
Verification Steps
-- pruning works for a market-scoped query
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title FROM listings
WHERE region = 'PT'
AND geom && ST_MakeEnvelope(-9.2, 38.6, -9.0, 38.8, 4326);
-- expect a single Index Scan on listings_pt_geom_idx
-- nothing has drifted into the wrong partition
SELECT 'PT' AS region, count(*) AS outside_extent
FROM listings_pt
WHERE NOT (geom && ST_MakeEnvelope(-9.6, 36.9, -6.1, 42.2, 4326))
UNION ALL
SELECT 'NO', count(*)
FROM listings_no
WHERE NOT (geom && ST_MakeEnvelope(4.0, 57.9, 31.6, 71.4, 4326));
-- is the default partition collecting anything?
SELECT region, count(*) FROM listings_default GROUP BY region;Gotchas Checklist
- Adding a partition for a new market takes a lock on the parent. Brief, but set
lock_timeoutand retry rather than blocking every insert during a launch. ST_GeoHashrequires geographic coordinates. On a projected SRID it returns nonsense without erroring. Cast or transform first, or useST_SnapToGridon projected data instead.- Updating the region column moves the row. That is a delete plus insert internally, so it churns both partitions’ indexes. If listings genuinely relocate across markets, reconsider the key.
- A generated column cannot reference another table. The error message mentions immutability rather than tables, which sends people down the wrong path — the fix is to compute the value in the insert path.
- Extent constraints must be maintained if borders change. They are constants; an administrative boundary change means updating them, and the update needs a full validation scan of that partition.
Related Topics
- Spatial Table Partitioning — parent topic: choosing a partition strategy
- Partitioning Spatial Tables by Time Range — the time-keyed variant and its maintenance job
- Indexing and Planning on Partitioned Spatial Tables — verifying pruning in the plan
- Point-in-Polygon Joins at Scale — the lookup that resolves a region code