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_SimplifyorST_SimplifyPreserveTopologyavailable on the engine, plusST_AsMVTif 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
SELECTon 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 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.
Configuration walkthrough
Serving models are ordinary dbt models in their own directory, with their own schema and materialization defaults.
# 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:
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:
-- 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.
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”.
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:
-- 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:
-- 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.
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.
Related
- Generating Vector Tiles from dbt Marts — the tile model end to end.
- Simplifying Geometries for Map Payloads — choosing tolerances per zoom band and testing them.
- Exposing GeoJSON API Endpoints from dbt Models — the feature-service side.
- Handling Large Geospatial Datasets — what to do upstream when the marts themselves are the problem.
Up: Part of Spatial Data Architecture & Governance.