← Back to Vector Tile Endpoints with ST_AsMVT
This page shows how to compute a simplification tolerance from the requested zoom level so low-zoom tiles stop shipping vertices that no screen can resolve.
Context & When to Use
A parcel boundary surveyed to centimetre accuracy might carry 340 vertices. At zoom 14 the whole parcel occupies perhaps 60 pixels and maybe 30 of those vertices are distinguishable. At zoom 8 the parcel is smaller than a single pixel and every vertex is waste — but the query still reads them, ST_AsMVTGeom still clips them, and the protobuf still encodes them. Across a dense tile this is the difference between a 690 KB payload and a 148 KB one, as measured on the ST_AsMVT topic page.
The insight that makes simplification safe is that a vector tile already has a resolution limit. ST_AsMVTGeom quantises coordinates onto a 4096-unit grid, so any detail finer than one tile unit is discarded regardless. Choosing a tolerance of one or two tile units therefore removes only information the format was going to throw away — the output is byte-for-byte smaller and pixel-for-pixel identical.
Apply this on every tile route that serves polygons or lines. Point layers need no simplification, since a point has one vertex; they need the feature-count controls covered under attribute budgeting instead. If your tiles are pre-rendered rather than generated per request, the same tolerance formula belongs in the generation job.
Runnable Implementation
-- Ground size of one tile unit at a zoom level, in Web Mercator metres.
-- 40075016.6855785 m is the equatorial circumference; 4096 is the MVT extent.
CREATE OR REPLACE FUNCTION tile_tolerance(z integer, units double precision DEFAULT 2)
RETURNS double precision
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT 40075016.6855785 / (2 ^ GREATEST(z, 0)) / 4096 * units;
$$;
-- Tile query with zoom-derived simplification applied BEFORE clipping
WITH bounds AS (
SELECT ST_TileEnvelope($1, $2, $3) AS merc,
ST_Transform(ST_TileEnvelope($1, $2, $3), 4326) AS wgs
),
tile AS (
SELECT f.id,
f.category_code,
ST_AsMVTGeom(
ST_SimplifyPreserveTopology(
ST_Transform(f.geom, 3857),
tile_tolerance($1) -- 2 tile units at this zoom
),
b.merc, 4096, 64, true
) AS geom
FROM features f
CROSS JOIN bounds b
WHERE f.geom && b.wgs
AND f.min_zoom <= $1
)
SELECT ST_AsMVT(tile.*, 'features', 4096, 'geom') AS mvt
FROM tile
WHERE geom IS NOT NULL;At zoom 14 that tolerance is about 1.2 m; at zoom 10, 19 m; at zoom 6, 306 m. Those are exactly the distances below which the tile grid cannot represent a difference, which is why the visual result is unchanged.
Key Parameters & Options
| Parameter | Typical | Effect |
|---|---|---|
units in tile_tolerance | 2 | Multiples of one tile unit. 1 is conservative, 2 is the sweet spot, 4 starts to be visible on straight edges |
ST_SimplifyPreserveTopology | always for polygons | Guarantees valid output; never drops a ring or a hole |
ST_Simplify | lines only, if at all | Faster but can self-intersect; acceptable for unfilled linework |
ST_SimplifyVW | alternative | Visvalingam-Whyatt; better on sinuous natural features, ~2× slower |
| Simplify position | before ST_AsMVTGeom | Simplifying afterwards works on tile units and undoes the clip buffer |
min_zoom gating | per feature | Removes whole features rather than vertices — the bigger win at low zoom |
Order matters more than the exact tolerance. Simplifying after clipping operates on already-quantised coordinates, gains almost nothing, and can pull vertices out of the buffer zone that keeps features continuous across tile seams.
Where the CPU actually goes
Because the cost is concentrated where the benefit is, there is no need to switch simplification off above a zoom threshold — the formula already makes it a no-op when the tolerance falls under the data’s own resolution.
Gotchas & Failure Modes
ERROR: TopologyException: found non-noded intersection—ST_SimplifyPreserveTopologywas handed geometry that was already invalid. Repair at write time withST_MakeValid, not per tile; the validation approach in Strict Pydantic Validation for Geometry stops most of it earlier.- Tolerance expressed in degrees. If the geometry has not been transformed to 3857 before simplifying, the tolerance is in degrees and a value of 19 flattens whole countries. Transform first, always.
- Sliver polygons collapsing. Very thin features — a road casing, a river polygon — can shrink below the tolerance and disappear. Gate them with
min_zoomso they are removed deliberately rather than as a side effect. - Simplify inside a subquery the planner reruns. Wrapping the call so it is evaluated per output row rather than once per feature multiplies the cost. Keep it in a single CTE stage as shown.
- Cached tiles keyed without the tolerance. If the multiplier is tuned later, previously cached tiles keep the old geometry. Put a tile-format version in the cache key — see Caching Vector Tiles at the Edge with Cache-Control.
- Assuming smaller is always better. Beyond about four tile units the simplification becomes visible as flattened corners on buildings and straightened curves on roads. Two is a good default; verify by eye before raising it.
When to precompute instead
Request-time simplification is the right default because there is nothing to keep in sync. It stops being right when the same low-zoom tiles are requested constantly against stable data — a country-level overview that thousands of users load on every session, over boundaries that change once a year.
At that point the arithmetic flips. Simplifying 60 000 vertices on every request to produce the same 14-vertex output is work you can do once. Materialise a per-zoom-band geometry column, populate it in the job that refreshes the source, and have the tile query select the column matching the requested band:
ALTER TABLE features
ADD COLUMN geom_z6 geometry(MultiPolygon, 3857),
ADD COLUMN geom_z10 geometry(MultiPolygon, 3857);
UPDATE features SET
geom_z6 = ST_SimplifyPreserveTopology(ST_Transform(geom, 3857), tile_tolerance(6)),
geom_z10 = ST_SimplifyPreserveTopology(ST_Transform(geom, 3857), tile_tolerance(10));Two bands are usually enough: one for the overview zooms and one for the middle range, with the high zooms reading the source geometry directly. The cost is storage — roughly 15 % of the source column for a z6 band and 40 % for z10 — plus the discipline of refreshing them whenever the geometry changes. Treat a stale band as a correctness bug, not a cosmetic one, and refresh it in the same transaction that writes the source.
Verification Snippet
-- Vertex count and payload before and after, same tile
WITH b AS (SELECT ST_TileEnvelope(8, 127, 84) AS merc,
ST_Transform(ST_TileEnvelope(8, 127, 84), 4326) AS wgs)
SELECT sum(ST_NPoints(ST_Transform(f.geom, 3857))) AS vertices_raw,
sum(ST_NPoints(ST_SimplifyPreserveTopology(
ST_Transform(f.geom, 3857), tile_tolerance(8)))) AS vertices_simplified
FROM features f, b
WHERE f.geom && b.wgs;
-- vertices_raw | vertices_simplified
-- --------------+---------------------
-- 418 022 | 62 118# Byte-level confirmation on the live route
curl -s -o /dev/null -w '%{size_download}\n' localhost:8000/v1/tiles/8/127/84.mvt
# 151392Related
- Vector Tile Endpoints with ST_AsMVT — the full tile query this tolerance plugs into
- Tile Generation & CDN Distribution — precomputing tiles when request-time simplification is not enough
- Materialized Views for Spatial Aggregations — the answer for zoom levels simplification cannot rescue
← Back to Vector Tile Endpoints with ST_AsMVT