Serving Spatial Data to Consumers

Every spatial pipeline eventually meets a consumer that does not speak SQL: a web map, a routing engine, a BI tool with a map widget, a feature store. This is where well-built pipelines most often disappoint, because the mart that is perfect for analysis — full-precision geometry, one row per feature, every attribute attached — is exactly the wrong shape for a browser that needs to draw ten thousand polygons in under a second. The serving layer is a separate design problem with its own units of measure: payload bytes, vertices per feature, requests per second.

This topic covers the last mile: what to materialize so a map is fast, how much precision to throw away and at which zoom level, and how to publish geometry without letting a client reach into the analytical models. It assumes the analytical layers are already in place — the layering itself is described in spatial data architecture and governance — and concentrates on what sits between the mart and the consumer.

Prerequisites checklist

  • Marts that are already correct. The serving layer amplifies whatever it is given; a fanout bug shipped as vector tiles is a fanout bug on every screen.
  • A stated payload budget — bytes per tile or per API response — agreed with whoever builds the client. Without a number, “too slow” is unfalsifiable.
  • ST_Simplify or ST_SimplifyPreserveTopology available on the engine, plus ST_AsMVT if you are generating vector tiles in the warehouse.
  • A separate schema for serving models, so grants can be narrow: a map client should never hold SELECT on the intermediate layer. The pattern belongs with data security scoping rules.
  • Zoom levels enumerated. Serving is not one artifact but one artifact per zoom band, and the band list has to be decided before the models are written.

Architecture context: the serving layer as its own tier

The serving tier between analytical marts and map, API and BI consumers Analytical marts on the left hold full-precision geometry. A serving tier in the middle contains three purpose-built models: simplified geometry per zoom band, a tile model producing binary vector tiles, and a narrow GeoJSON feature model. On the right, three consumers — a web map, an API and a BI tool — each read from exactly one serving model and never from the marts, which is marked with a blocked connection. analytical marts full precision every attribute one row per feature geometry by zoom band ST_Simplify per band tile model ST_AsMVT · z/x/y keyed feature model narrow GeoJSON rows BI map widget reads one band web map reads tiles only API service reads features no direct mart access

The rule the diagram encodes is worth stating plainly: consumers read serving models, never marts. It costs a little duplication and buys three things — you can change a mart’s shape without breaking a map, you can grant narrowly, and you can measure exactly what each consumer costs, because each has its own model.

Core concepts: what serving actually optimizes

Analytical models optimize for correctness and reuse. Serving models optimize for three quantities that never appear in an analytical review:

Quantity Typical budget What blows it Lever
Bytes per tile 50–500 KB Full-precision geometry at low zoom ST_Simplify per zoom band
Vertices per feature < 1,000 at overview zoom Coastlines, administrative boundaries Simplification and clipping
Attributes per feature 3–8 “Send everything, the client will filter” An explicit column contract
Features per response < 10,000 Missing a bounding-box filter Spatial predicate on the request extent
Time to first paint < 1 s Any of the above All of the above, measured

Precision is the lever people reach for last and should reach for first. Coordinates stored at fifteen decimal places describe a position to within a fraction of a micron; a map at city zoom cannot resolve better than a metre. Rounding coordinates to six decimals before serving typically removes a third of the payload with no visible difference, and it compresses better afterwards because the digits repeat.

Where the bytes go in one overview-zoom map response, before and after a serving model Two stacked bars of the same response. Before, the payload is dominated by coordinate precision beyond six decimals and by unused attributes, with actual shape a small fraction. After, coordinates are rounded, geometry is simplified for the zoom band and only the contracted attributes remain, cutting the payload from 4.2 megabytes to 310 kilobytes with no visible change on screen. mart served directly · 4.2 MB excess coordinate precision attributes nobody renders vertices shape serving model · 310 KB rounded coordinates, band-simplified shape, contracted columns Nothing visible was removed — the map draws the same picture from a fourteenth of the bytes.

Configuration walkthrough

Serving models are ordinary dbt models in their own directory, with their own schema and materialization defaults.

yaml
# dbt_project.yml
models:
  dbt_geospatial:
    serving:
      +schema: serving
      +materialized: table
      +tags: ['serving']
      +post-hook: "GRANT SELECT ON {{ this }} TO ROLE map_client"

Zoom bands belong in project vars, not scattered through models, so a change to the simplification policy is one edit:

yaml
vars:
  zoom_bands:
    - {name: 'overview', min_zoom: 0,  max_zoom: 6,  tolerance_m: 2000}
    - {name: 'regional', min_zoom: 7,  max_zoom: 10, tolerance_m: 200}
    - {name: 'local',    min_zoom: 11, max_zoom: 14, tolerance_m: 20}
    - {name: 'detail',   min_zoom: 15, max_zoom: 20, tolerance_m: 0}

A model then loops over the bands rather than repeating itself:

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, {{ band.tolerance_m }}) as geom
    {%- else -%}
    geom
    {%- endif %}
from {{ ref('mart_zones') }}
{% if not loop.last %}union all{% endif %}
{% endfor %}

ST_SimplifyPreserveTopology rather than ST_Simplify is the important choice for polygons: plain simplification can produce self-intersections and can collapse narrow features to nothing, and both defects arrive as rendering artefacts rather than as errors.

Writing the consumer contract down

The serving layer only works if both sides know what is promised. A contract that fits in a dozen lines of the model’s description prevents most of the arguments that otherwise happen after a release: which columns exist, what the geometry’s precision and simplification are, how often it rebuilds, and what happens when a request exceeds a limit.

yaml
models:
  - name: serve_zone_geometry
    description: >
      Zone geometry for map rendering, one row per zone per zoom band.
      Contract: columns zone_id, zone_name, band_name, min_zoom, max_zoom, geom.
      Geometry is EPSG:3857, simplified per band, coordinates rounded to ~11 cm.
      Rebuilt nightly at 02:00 UTC. Consumers must filter on band_name and a
      bounding box. Breaking changes ship as serve_zone_geometry_v2 with a
      90-day overlap; additive columns may appear without notice.

Two clauses in that description carry most of the weight. “Consumers must filter” states the obligation on the client side, which is what makes an unfiltered query a client bug rather than a platform surprise. And the versioning clause sets the expectation before anyone depends on it, so the first breaking change is a scheduled migration instead of an incident.

The contract also decides who absorbs a change. If the map’s style file hard-codes a layer name and a set of attribute keys, then the serving model’s column list is effectively public API — and renaming a column becomes a coordinated release rather than a refactor. Teams that skip this step usually discover it during the first attempt to rename something, at which point the safest option is to keep both names forever. Writing the contract early is what keeps that option from becoming the default.

Cadence: how fresh does the map need to be?

Freshness is the other axis, and it is worth deciding explicitly rather than inheriting from the analytical schedule. Three cadences cover almost every case. A nightly rebuild suits reference geometry — administrative boundaries, service areas, infrastructure — which changes on a release schedule measured in weeks. An hourly or micro-batch rebuild suits derived attributes such as activity bands or availability counts, where the geometry is stable and only the colour on the map changes; splitting those into a small attribute model that rebuilds often, joined client-side to a rarely-rebuilt geometry model, is much cheaper than rebuilding tiles hourly. And for anything that must be current within seconds — vehicle positions, live incidents — the answer is not a dbt model at all, but a direct read from the operational store, with the dbt-built geometry providing only the static backdrop.

Naming the cadence in the contract has a practical benefit beyond expectation-setting: it tells you which artifacts can be cached and for how long, which is the single biggest lever on serving cost once the payload sizes are under control.

Validation and testing

Serving models need tests analytical models do not, because their failure mode is “slow and ugly” rather than “wrong”.

yaml
models:
  - name: serve_zone_geometry
    tests:
      - dbt_utils.expression_is_true:
          expression: "st_npoints(geom) < 20000"
          config:
            where: "band_name = 'overview'"
    columns:
      - name: geom
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "st_isvalid(geom)"

The validity test matters most on simplified geometry, since simplification is the step most likely to introduce an invalid ring. Pair it with a coverage test that catches features simplified out of existence entirely:

sql
-- tests/assert_no_features_vanished.sql
select b.zone_id
from {{ ref('mart_zones') }} b
left join {{ ref('serve_zone_geometry') }} s
  on b.zone_id = s.zone_id and s.band_name = 'overview'
where s.zone_id is null

Finally, assert the payload budget itself, which is the number the client actually experiences:

sql
-- tests/assert_tile_payload_budget.sql
select z, x, y, octet_length(mvt) as bytes
from {{ ref('serve_zone_tiles') }}
where octet_length(mvt) > 500000

Advanced patterns

Pre-generate tiles for the zoom levels people actually use, compute the rest on demand. Tile pyramids grow by a factor of four per zoom level, so materializing to zoom 14 for a whole country is enormous while materializing to zoom 10 is modest. Instrument the client to find the real distribution before deciding where to stop.

Clip features to tile boundaries before encoding. An unclipped polygon is repeated in full in every tile it touches, which for a large administrative area means the same megabyte in dozens of tiles. ST_AsMVTGeom does the clipping and coordinate transformation together.

Keep attributes out of the geometry payload. Send an id in the tile and let the client fetch attributes separately for the features it actually shows. This decouples a label change from a tile rebuild, which otherwise means regenerating the pyramid.

Version the serving contract. When a consumer depends on a column list, that list is an interface; publish serve_zones_v2 alongside v1 and retire the old one on a schedule, rather than mutating a model that a production map reads. The schema-change discipline in versioning spatial schemas in dbt applies directly.

Watch the cost of regeneration. A nightly full rebuild of a tile pyramid can quietly become the most expensive job in the project; track it with the techniques in spatial observability and cost control.

Tile counts by zoom level and where pre-generation stops paying A pyramid of tile counts. Zoom six holds a few thousand tiles for a country, zoom ten holds roughly a million, zoom fourteen holds hundreds of millions. A horizontal line marks the practical pre-generation ceiling around zoom ten to twelve, above which tiles are generated on demand and cached, because each further level multiplies the count by four. z6 ~4K tiles z8 ~65K tiles z10 ~1M tiles z12 ~17M tiles z14 ~270M pre-generate on demand + cache Each level multiplies by four — the ceiling is a budget decision, not a technical limit.

Measuring the last mile honestly

One habit separates serving layers that stay fast from those that drift: measuring from where the consumer sits, not from where the query runs. A tile query that completes in 40 ms in the warehouse can still take two seconds to appear on screen, because the payload crossed a network, was decompressed, parsed and then drawn. Capture the four numbers separately — query time, transfer bytes, parse time and render time — and the conversation about “the map is slow” becomes a conversation about which of the four moved.

The cheapest instrumentation is usually already available. Warehouse query history gives the first number, the response’s content length gives the second, and a browser performance mark around the client’s decode and draw calls gives the last two. Record them per zoom band, because a serving layer that is comfortable at overview zoom and painful at street level has a band problem rather than a platform problem, and the fix is a tolerance change rather than an architecture change.

Troubleshooting

Symptom Root cause Fix
Map is slow only when zoomed out Full-precision geometry served at overview zoom Add a simplified band with a tolerance in the hundreds of metres
Polygons develop spikes or holes after simplification ST_Simplify used on polygons Switch to ST_SimplifyPreserveTopology and add a validity test
Small features disappear at low zoom Simplification tolerance exceeds the feature size Filter by area per band and keep a centroid marker for the removed features
The same polygon appears in dozens of tiles at full size Geometry not clipped to the tile Use ST_AsMVTGeom to clip and transform in one step
Tile rebuild dominates the nightly run Pyramid materialized deeper than anyone browses Lower the pre-generation ceiling and cache the rest
Client sees columns disappear after a release Serving model mutated in place Version the serving contract and retire on a schedule

FAQ

Should tiles be generated in the warehouse or by a tile server?

In the warehouse when the data changes on a build cadence and the pyramid is shallow — the whole thing is then one dbt model with tests, lineage and a schedule. Use a dedicated tile server when tiles must reflect changes within seconds, or when the pyramid goes deep enough that storing it is more expensive than generating on demand. The two approaches coexist happily: pre-generate the shallow levels in dbt, serve the deep ones dynamically.

How much simplification is too much?

The point where a feature’s shape stops being recognisable at its intended zoom, which is a visual judgement rather than a number. A workable procedure: render the same feature at each candidate tolerance, look at them side by side at the target zoom, and pick the largest tolerance that is indistinguishable. Then encode that as the band’s tolerance and test the vertex count so it cannot drift.

Can consumers query the marts directly if they promise to filter?

They can, and eventually one of them will forget the filter. The cost of a separate serving model is small; the cost of a map client scanning an analytical table on every pan is not. Granting narrowly also makes each consumer’s usage measurable, which is what turns “the warehouse is expensive” into a specific, fixable line item.

What belongs in a serving model that does not belong in a mart?

Rounded coordinates, per-zoom simplified geometry, precomputed colour or category buckets, and a stable feature id. What does not belong: anything a consumer might want “just in case”. A serving model with thirty columns is a mart with extra steps.

How do I keep serving models from drifting away from the marts?

Build them from the marts with ref(), never from the sources, and add a row-count and key-coverage test between the two. Drift then fails a build rather than appearing as a missing feature on a map that nobody notices for a week.

Up: Part of Spatial Data Architecture & Governance.

Explore this section