Simplifying geometries for map payloads
This page builds a per-zoom simplification model in dbt: choosing a tolerance that is invisible at the target zoom, applying it without breaking topology, rounding coordinates to the precision a screen can actually resolve, and testing that nothing vanished or turned invalid on the way.
When to use this approach
- A map is slow at low zoom and fine when zoomed in. That is the signature of full-precision geometry being sent for an overview, and simplification is the direct fix.
- Payloads are large but feature counts are not. Few features and many megabytes means the bytes are in vertices, not in rows.
- You are about to build vector tiles. Simplify first: clipping reduces duplication between tiles, simplification reduces the size of each one. Both are needed, as noted in generating vector tiles from dbt marts.
Prerequisites
ST_SimplifyPreserveTopologyon the engine, or an equivalent that does not self-intersect polygons.- Valid input geometry — simplification of an already-invalid polygon produces a differently-invalid polygon.
- A projected CRS for the tolerance to be expressed in metres, or a geography type that measures in metres natively.
- Agreed zoom bands, defined once in project vars as described in serving spatial data to consumers.
Step-by-step instructions
1. Work out what one screen pixel is worth
Every simplification tolerance is really a statement about pixels. At zoom level z in Web Mercator, one pixel covers roughly 156543 / 2^z metres at the equator. Anything smaller than a pixel cannot be seen, so a tolerance of one to two pixels is invisible by construction.
-- analyses/pixel_size_by_zoom.sql
select
z,
round((156543.03392 / power(2, z))::numeric, 3) as metres_per_pixel,
round((156543.03392 / power(2, z) * 1.5)::numeric, 1) as suggested_tolerance_m
from generate_series(0, 16) as z
z | metres_per_pixel | suggested_tolerance_m
----+------------------+----------------------
6 | 2445.985 | 3668.9
8 | 611.496 | 917.2
10 | 152.874 | 229.3
12 | 38.219 | 57.3
14 | 9.555 | 14.3
Verify the numbers against your own extent — at latitude 55 the true ground distance per pixel is about 57 per cent of the equatorial figure, so a tolerance derived from the table above is conservative for high-latitude data and about right for mid-latitudes.
2. Simplify with topology preserved
-- models/serving/serve_zone_geometry.sql
{{ config(materialized = 'table', cluster_by = ['band_name']) }}
{% for band in var('zoom_bands') %}
select
zone_id,
zone_name,
'{{ band.name }}' as band_name,
{{ band.min_zoom }} as min_zoom,
{{ band.max_zoom }} as max_zoom,
{% if band.tolerance_m > 0 -%}
st_simplifypreservetopology(geom_3857, {{ band.tolerance_m }}) as geom
{%- else -%}
geom_3857 as geom
{%- endif %},
st_npoints({% if band.tolerance_m > 0 %}st_simplifypreservetopology(geom_3857, {{ band.tolerance_m }}){% else %}geom_3857{% endif %}) as vertex_count
from {{ ref('mart_zones') }}
{% if not loop.last %}union all{% endif %}
{% endfor %}
Carrying vertex_count as a column looks redundant but pays for itself: it makes the effect of each tolerance queryable without recomputing geometry, and it is what the tests in step 4 assert against.
Verify the reduction per band:
select
band_name,
count(*) as features,
sum(vertex_count) as total_vertices,
round(avg(vertex_count)) as avg_vertices
from {{ ref('serve_zone_geometry') }}
group by band_name order by avg_vertices desc;
-- Expect roughly an order of magnitude between adjacent bands
3. Round coordinates to the precision that is actually used
Simplification removes vertices; rounding shrinks the ones that remain. Six decimal places in WGS84 is about 11 cm — finer than any map a browser will draw.
select
zone_id,
st_reduceprecision(geom, 0.000001) as geom -- PostGIS 3.1+
from {{ ref('serve_zone_geometry') }}
On older PostGIS the equivalent is ST_SnapToGrid(geom, 0.000001). Either way, check the effect on the serialized size rather than on the geometry:
select
pg_size_pretty(sum(octet_length(st_asgeojson(geom)::text))::bigint) as geojson_bytes
from {{ ref('serve_zone_geometry') }}
where band_name = 'overview';
-- Compare before and after; a third smaller is typical
4. Test for the two things simplification breaks
# models/serving/schema.yml
models:
- name: serve_zone_geometry
columns:
- name: geom
tests:
- not_null
- dbt_utils.expression_is_true:
expression: "st_isvalid(geom)"
- name: vertex_count
tests:
- dbt_utils.accepted_range:
min_value: 4
max_value: 20000
Then assert that every source feature still exists in every band — the failure this catches is a small feature simplified out of existence, which a validity test will not notice:
-- tests/assert_all_features_present_per_band.sql
with expected as (
select z.zone_id, b.band_name
from {{ ref('mart_zones') }} z
cross join (select distinct band_name from {{ ref('serve_zone_geometry') }}) b
)
select e.zone_id, e.band_name
from expected e
left join {{ ref('serve_zone_geometry') }} s
on e.zone_id = s.zone_id and e.band_name = s.band_name
where s.zone_id is null
Verify the test can fail by adding a deliberately tiny fixture polygon and confirming it disappears at the overview tolerance — then decide whether to keep it as a centroid marker or accept its absence.
5. Keep the small features that matter
When a feature is genuinely too small to draw at a zoom band but must still appear — an island, a depot, a single-building zone — replace its geometry rather than dropping the row.
select
zone_id,
band_name,
case
when st_area(geom) < {{ band.tolerance_m }} * {{ band.tolerance_m }} * 4
then st_centroid(geom)
else geom
end as geom,
st_area(geom) < {{ band.tolerance_m }} * {{ band.tolerance_m }} * 4 as is_point_proxy
from ...
The is_point_proxy flag lets the client style those features as markers instead of shapes, which is both honest and better-looking than a polygon reduced to a triangle.
Verify how many features fall back to a proxy at each band, and sanity-check that the number falls as zoom increases:
select band_name, count(*) filter (where is_point_proxy) as proxies, count(*) as total
from {{ ref('serve_zone_geometry') }} group by band_name;
Configuration reference
| Parameter | Where | Typical value | Note |
|---|---|---|---|
tolerance_m |
zoom-band var | 1.5 × metres per pixel | Derive from the zoom, not from taste |
ST_SimplifyPreserveTopology |
model SQL | — | Use for polygons; plain ST_Simplify for lines where self-intersection is impossible |
ST_ReducePrecision grid |
model SQL | 0.000001 (≈11 cm) | Coordinate rounding; use ST_SnapToGrid before PostGIS 3.1 |
| min vertex count | test | 4 | A polygon below four points is degenerate |
| point-proxy area threshold | model SQL | 4 × tolerance² | Below this a shape is not worth drawing as a shape |
| band list | project vars | 3–5 bands | More bands means more storage and more rebuild time |
Gotchas & edge cases
ST_Simplifyon polygons can create self-intersections and holes, and the result renders as visual noise rather than raising an error. Use the topology-preserving variant and keep the validity test.- Tolerance is in the SRID’s units. Applied to EPSG:4326 geometry a value of
200means 200 degrees, which erases everything. Simplify in a projected CRS. - Shared borders drift apart. Simplifying two adjacent polygons independently moves their common edge differently, opening slivers. Where seamless coverage matters, simplify the shared boundary network rather than the polygons.
- Rounding after simplification, not before. Rounding first moves vertices onto a grid and makes the simplifier’s distance calculations less effective.
- The overview band is the one that matters. It is where payloads are largest and precision least needed, and it is usually the band people forget to tune.
FAQ
How do I pick a tolerance without guessing?
Compute metres per pixel for the band’s maximum zoom, multiply by one to two, and use that. Then confirm visually at that zoom — the arithmetic gets you to the right order of magnitude and the eye settles the last factor of two. Recording the derivation next to the band definition stops the next person from treating it as a magic number.
Should lines and polygons use the same tolerance?
The same distance, yes — it is a screen-resolution argument and applies to both. The function differs: lines can use plain ST_Simplify safely because a simplified line cannot break a polygon’s ring topology, while polygons need the preserving variant.
Does simplification change analytical results?
It would, which is exactly why it belongs in the serving layer and nowhere else. Areas, lengths and point-in-polygon assignments must be computed from full-precision geometry in the marts; the simplified copies exist only to be drawn. Mixing the two is how a dashboard and a map end up disagreeing about the same number.
What about topology across a whole coverage, like administrative units?
Independent simplification will open gaps between neighbours. The robust approach is to extract the shared boundary network, simplify each boundary once, and rebuild polygons from the simplified edges. PostGIS’s topology extension supports this; where it is not available, accept small slivers at overview zoom and render with a slight stroke that hides them.
Related
- Serving Spatial Data to Consumers — where simplification sits in the serving tier.
- Generating Vector Tiles from dbt Marts — the step that follows this one.
- Densifying and Segmentizing Geometries in dbt — the inverse operation, and when you need it.
Up: Part of Serving Spatial Data to Consumers.