Simplifying Geometry Per Zoom Level for Vector Tiles

Derive a simplification tolerance from the zoom level, keep polygon topology valid, and cut low-zoom tile payloads by 80% without visible change on screen.

← 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.

The same outline at three zoom-derived tolerancesThree copies of a coastline polygon. The first, at full detail, carries 340 vertices and is 11.4 kilobytes. The second, simplified with a zoom-14 tolerance of 1.2 metres, carries 96 vertices and is 3.2 kilobytes, and its outline is visually identical. The third, simplified with a zoom-8 tolerance of 153 metres, carries 14 vertices and is 0.5 kilobytes, and at that zoom the shape occupies only a few pixels so the loss is invisible on screen.One outline, three tolerances — and what the screen can showsource340 vertices · 11.4 KBtolerance 0z14 · 1.2 m96 vertices · 3.2 KBvisually identicalz8 · 153 m14 vertices · 0.5 KBat z8 this is ~3 px wideDetail is only waste relative to a display scale — the same third shape at zoom 14 would be an obvious error.That is the entire argument for deriving tolerance from zoom rather than fixing it.

Key Parameters & Options

ParameterTypicalEffect
units in tile_tolerance2Multiples of one tile unit. 1 is conservative, 2 is the sweet spot, 4 starts to be visible on straight edges
ST_SimplifyPreserveTopologyalways for polygonsGuarantees valid output; never drops a ring or a hole
ST_Simplifylines only, if at allFaster but can self-intersect; acceptable for unfilled linework
ST_SimplifyVWalternativeVisvalingam-Whyatt; better on sinuous natural features, ~2× slower
Simplify positionbefore ST_AsMVTGeomSimplifying afterwards works on tile units and undoes the clip buffer
min_zoom gatingper featureRemoves 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

Tile generation time with and without zoom-derived simplificationFour stacked bars. At zoom 8 without simplification the tile takes 210 milliseconds, split into 12 for the index scan, 138 for clipping and 60 for encoding. At zoom 8 with simplification it takes 74 milliseconds: 12 scan, 22 simplify, 28 clip, 12 encode — the simplifier costs 22 but saves 116 downstream. At zoom 14 without simplification the tile takes 23 milliseconds and with simplification 26, so at high zoom the work is slightly wasted and the tolerance falls below the data resolution anyway.Median tile generation, dense parcel layerscansimplifyclipencodez8 · none210 msz8 · derived74 ms — 2.8× fasterz14 · none23 msz14 · derived26 ms — slightly worse, harmlessSimplification pays for itself at low zoom by shrinking the clip stage; at high zoom it is a rounding error either way.

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 intersectionST_SimplifyPreserveTopology was handed geometry that was already invalid. Repair at write time with ST_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_zoom so 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.
What the derived tolerance works out to at each zoomSix zoom levels with the ground size of one tile unit and the resulting two-unit tolerance. At zoom 6 one unit is 153 metres and the tolerance 306. At zoom 8, 38 and 77. At zoom 10, 9.6 and 19. At zoom 12, 2.4 and 4.8. At zoom 14, 0.6 and 1.2. At zoom 16, 0.15 and 0.3. A note marks that from zoom 15 the tolerance drops below typical survey accuracy, so simplification becomes a no-op automatically without needing a threshold in the code.Derived tolerance by zoom levelzoom1 tile unittolerance (2 units)what it removes6153 m306 mwhole buildings, minor road bends838 m77 mbuilding detail, kerb lines109.6 m19 mparcel corners122.4 m4.8 msurvey noise14 · 160.6 · 0.15 m1.2 · 0.3 mbelow survey accuracy — a no-opThe formula switches itself off at high zoom, so no threshold is needed in the query.

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
# 151392

← Back to Vector Tile Endpoints with ST_AsMVT