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_SimplifyPreserveTopology on 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.

sql
-- 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
text
 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.

A coastline drawn at three tolerances against the pixel size of its target zoom The same coastline segment drawn three times. At full precision it has many small wiggles. At a tolerance of one and a half pixels for the target zoom it is visibly identical but has a fraction of the vertices. At ten times that tolerance the shape is noticeably straightened and a bay has disappeared. A pixel-sized square is shown for scale beside each, making clear why the middle version is indistinguishable on screen. full precision · 4,812 vertices = one screen pixel at z12 1.5 px tolerance · 391 vertices indistinguishable on screen 15 px tolerance · 31 vertices a bay has disappeared Pick the largest tolerance that stays under a pixel or two — beyond that the map starts lying.

2. Simplify with topology preserved

sql
-- 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:

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

sql
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:

sql
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
Payload size of one zone layer after each reduction step Four bars showing the same layer's serialized size. The mart at full precision is 18.4 megabytes. After simplification to the overview band tolerance it is 2.1 megabytes. After coordinate rounding to six decimals it is 1.4 megabytes. After dropping attributes not in the client contract it is 0.9 megabytes. Each step is labelled with what it removed and whether the change is visible on screen. mart, full precision 18.4 MB + simplified to band tolerance 2.1 MB no visible change at this zoom + coordinates rounded to 6 dp 1.4 MB 11 cm precision, well below a pixel + contracted attribute list 0.9 MB only what the client renders Twenty times smaller, and identical on screen at the zoom it was built for.

4. Test for the two things simplification breaks

yaml
# 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:

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

Two ways simplification breaks a polygon, and the test that catches each On the left, a narrow S-shaped polygon whose opposite edges are pulled together by a tolerance larger than its width, producing a self-intersection; the caption names the validity test as the catch. On the right, a small island polygon smaller than the tolerance, which disappears entirely; the caption names the per-band presence test as the catch, since the result is still valid geometry. a narrow neck folds over itself before after: self-intersecting caught by the ST_IsValid test a small feature vanishes smaller than the tolerance circle no row at all and still valid caught only by the per-band presence test

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.

sql
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:

sql
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_Simplify on 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 200 means 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.

Up: Part of Serving Spatial Data to Consumers.