Problem Statement
A vector-tile endpoint serves a national parcel dataset. At zoom 8 each tile covers thousands of parcels averaging four hundred vertices, and the endpoint spends nearly all of its time in ST_SimplifyPreserveTopology — work that produces the same answer for every request that touches those parcels, recomputed from scratch each time. This page applies materialized-view caching to that specific shape: pre-simplified geometry per zoom band, indexed and refreshed on a schedule.
Why the Naive Approach Fails
The straightforward tile query does everything per request:
SELECT ST_AsMVT(t, 'parcels') FROM (
SELECT id, ST_AsMVTGeom(
ST_SimplifyPreserveTopology(geom, %(tolerance)s),
ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom
FROM parcels
WHERE geom && ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326)
) t;The && filter is index-driven and fast. The simplification is not indexable at all — it is pure computation over every surviving vertex, and at low zoom the surviving set is enormous.
Production-Ready Implementation
One view per zoom band, each holding geometry simplified to that band’s resolution:
-- Band A: zooms 6–10. Tolerance in degrees, roughly a 300 m feature threshold.
CREATE MATERIALIZED VIEW parcels_z6_10 AS
SELECT
p.id,
p.parcel_ref,
ST_SimplifyPreserveTopology(p.geom, 0.0027) AS geom,
now() AS computed_at
FROM parcels p
WHERE ST_Area(p.geom::geography) > 5000 -- drop features invisible at this zoom
WITH DATA;
CREATE UNIQUE INDEX parcels_z6_10_pkey ON parcels_z6_10 (id);
CREATE INDEX parcels_z6_10_geom_idx ON parcels_z6_10 USING gist (geom);
-- Band B: zooms 11–13, finer tolerance and no area filter.
CREATE MATERIALIZED VIEW parcels_z11_13 AS
SELECT p.id, p.parcel_ref,
ST_SimplifyPreserveTopology(p.geom, 0.00017) AS geom,
now() AS computed_at
FROM parcels p
WITH DATA;
CREATE UNIQUE INDEX parcels_z11_13_pkey ON parcels_z11_13 (id);
CREATE INDEX parcels_z11_13_geom_idx ON parcels_z11_13 USING gist (geom);Dropping small features at low zoom is as valuable as the simplification itself: a parcel under five thousand square metres occupies less than a pixel at zoom 8, so shipping it costs bytes and renders nothing.
The endpoint picks its source by zoom:
from dataclasses import dataclass
@dataclass(frozen=True)
class ZoomBand:
lo: int
hi: int
relation: str
BANDS = (
ZoomBand(6, 10, "parcels_z6_10"),
ZoomBand(11, 13, "parcels_z11_13"),
ZoomBand(14, 22, "parcels"), # raw table: already cheap up here
)
def relation_for(z: int) -> str:
for band in BANDS:
if band.lo <= z <= band.hi:
return band.relation
raise ValueError(f"zoom {z} outside the served range")
TILE_SQL = """
SELECT ST_AsMVT(t, 'parcels') FROM (
SELECT id, parcel_ref,
ST_AsMVTGeom(geom, ST_Transform(env.geom, 4326)) AS geom
FROM {relation}, LATERAL (
SELECT ST_TileEnvelope(%(z)s, %(x)s, %(y)s) AS geom
) env
WHERE geom && ST_Transform(env.geom, 4326)
) t
"""
def tile(conn, z: int, x: int, y: int) -> bytes:
sql = TILE_SQL.format(relation=relation_for(z)) # relation is from a fixed set
with conn.cursor() as cur:
cur.execute(sql, {"z": z, "x": x, "y": y})
return cur.fetchone()[0]The relation name is interpolated from a closed set defined in code, never from request input — the zoom is validated against the bands before it reaches the format string.
Configuration and Tuning Knobs
Tolerance per band should be derived from tile resolution, not guessed. A tile is 4096 units across and spans 360/2^z degrees, so a tolerance of about 360 / (2**z * 4096) * 2 degrees keeps the error under two tile units — invisible, and aggressive enough to matter.
The area filter at low zoom is the bigger win on parcel-like data. Set the threshold so a feature occupies at least one square pixel at the band’s lowest zoom, and expect to remove most of the rows.
Refresh schedule follows the source data. A cadastral dataset updated nightly wants a nightly refresh right after the import, not an hourly one. Tie the refresh to the import job rather than to the clock where you can.
maintenance_work_mem affects the refresh, which builds indexes on the new contents. Raising it for the refresh session shortens the window measurably.
Verification Steps
-- the cache is materially smaller than the source, or it is not doing its job
SELECT 'parcels' AS relation, pg_size_pretty(pg_total_relation_size('parcels'))
UNION ALL
SELECT 'parcels_z6_10', pg_size_pretty(pg_total_relation_size('parcels_z6_10'))
UNION ALL
SELECT 'parcels_z11_13', pg_size_pretty(pg_total_relation_size('parcels_z11_13'));
-- vertex reduction achieved at each band
SELECT 'raw' AS band, round(avg(ST_NPoints(geom))) AS avg_vertices FROM parcels
UNION ALL
SELECT 'z6_10', round(avg(ST_NPoints(geom))) FROM parcels_z6_10
UNION ALL
SELECT 'z11_13', round(avg(ST_NPoints(geom))) FROM parcels_z11_13;And visually: render the same tile from the cache and from the raw table at the band’s highest zoom, and compare. The tolerance is right when the two are indistinguishable at that zoom and clearly different at the next one up.
Keeping the cached geometry honest
A cache of simplified geometry is a derived dataset, and derived datasets drift. Two checks keep it trustworthy.
The first is a row-count reconciliation after each refresh: the cached view should hold exactly the rows its filter implies, and a divergence means the filter and the source disagree — usually because a feature’s area crossed the threshold. The second is a validity check, because simplification can produce invalid polygons even with the topology-preserving variant when the input was itself invalid.
SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS invalid_cached
FROM parcels_z6_10;Any non-zero result points back at the source table, not at the cache. Fixing it there — see geometry validity and repair — is the durable answer; repairing inside the view definition just hides the problem behind a refresh schedule.
Gotchas Checklist
ST_Simplifycan invalidate polygons. UseST_SimplifyPreserveTopologyin a cache that other queries will read, or you have cached invalid geometry and made it permanent.- The unique index must exist before the first concurrent refresh. Adding it later means a blocking refresh in between.
- A view that filters by area needs the filter documented in the API. Callers who compare counts between zoom levels will otherwise report the difference as a bug.
- Do not cache the tile bytes in PostgreSQL. There are orders of magnitude more tiles than features, and a CDN does that job better; cache the geometry that produces them.
- Watch total buffer usage. Two large materialized views plus the source table can push the working set out of
shared_buffers, which slows everything including the queries that were fine.
One more measurement worth taking
Before declaring the cache a success, check the response size as well as the latency. Simplification reduces vertices, and fewer vertices means smaller tiles — often by more than the latency improvement suggests. A tile that drops from 240 KB to 38 KB is a better outcome for a mobile client on a slow connection than the fifty-fold server-side speedup, and it is the number a product owner will recognise.
Related Topics
- Materialized Views for Spatial Caching — parent topic: when a cache is the right tool
- Refreshing Materialized Views Concurrently — keeping these views current without blocking tiles
- Tile-Based Bounding Box Queries for Web Maps — the endpoint this cache sits behind
- Choosing Between a Cache and an Index — confirming the query is compute-bound first