Exposing GeoJSON API endpoints from dbt models
This page builds the warehouse side of a GeoJSON feature service: a serving model whose rows are already features, a query contract that forces callers to bound their requests, grants narrow enough that the API user cannot reach the marts, and tests that keep a response inside its size budget.
When to use this approach
- Consumers want features, not tiles. A search result list, a routing input, an export button and a “what is near me” call all want individual features with attributes — that is GeoJSON’s shape, not a tile’s.
- The client is not a map renderer. Vector tiles are optimized for drawing; a mobile app that needs the five nearest depots wants five objects, and encoding them as tiles is work nobody benefits from. The comparison is in serving spatial data to consumers.
- You need a service without a new pipeline. The API layer here is a thin read over a dbt-built table, so the data keeps its lineage and tests.
Prerequisites
ST_AsGeoJSONon the engine — available on PostGIS, BigQuery, Snowflake and DuckDB.- A serving schema with its own role, per data security scoping rules.
- A simplified geometry band to serve from; full-precision geometry in an API response is the same mistake as in a tile.
- An agreed response contract: maximum feature count, maximum payload bytes, and the required bounding-box parameter.
Step-by-step instructions
1. Shape the model like a feature, not like a row
A GeoJSON Feature has three parts — a geometry, a properties object, and an id. Build them as columns so the API layer does nothing but concatenate.
-- models/serving/serve_zone_features.sql
{{ config(
materialized = 'table',
indexes = [{'columns': ['geom'], 'type': 'gist'}, {'columns': ['zone_id'], 'unique': true}]
) }}
select
z.zone_id as feature_id,
z.geom,
jsonb_build_object(
'name', z.zone_name,
'category', z.zone_category,
'updated_at', z.updated_at,
'activity', a.activity_band
) as properties,
st_asgeojson(z.geom, 6)::jsonb as geometry_json,
z.geom::box2d::text as bbox_text,
'{{ run_started_at }}' as built_at
from {{ ref('serve_zone_geometry') }} as z
left join {{ ref('mart_zone_activity') }} as a using (zone_id)
where z.band_name = 'local'
The 6 in ST_AsGeoJSON is the coordinate precision, and it is the cheapest byte reduction available — roughly 11 cm on the ground, far below what any client resolves.
Verify a row is a valid feature before writing any API code:
select jsonb_build_object(
'type', 'Feature',
'id', feature_id,
'geometry', geometry_json,
'properties', properties
) as feature
from {{ ref('serve_zone_features') }} limit 1;
-- Paste the result into any GeoJSON validator; it should parse as a single Feature
2. Make the bounding box a required parameter
An endpoint without a mandatory spatial filter will eventually be called without one, and the response will be the whole table. Encode the contract in the query the service runs.
-- The service's parameterized query, not a dbt model
select
feature_id,
geometry_json,
properties
from serving.serve_zone_features
where geom && st_makeenvelope($1, $2, $3, $4, 4326) -- minx, miny, maxx, maxy
and st_intersects(geom, st_makeenvelope($1, $2, $3, $4, 4326))
order by feature_id
limit $5 -- hard ceiling, not caller-controlled
Two properties matter. The && operator makes the GiST index serve the filter, following the pattern in tuning ST_Intersects joins with bounding-box prefilters. And the limit is a server-side constant with a caller-supplied value clamped below it, so a client asking for a million features receives the ceiling and a truncation flag rather than a timeout.
Verify the index is used for a realistic viewport:
explain (analyze) select feature_id from serving.serve_zone_features
where geom && st_makeenvelope(13.3, 52.4, 13.5, 52.6, 4326);
-- Expect an Index Scan using the GiST index, not a Seq Scan
3. Grant only what the service needs
-- macros/grant_serving_reader.sql
{% macro grant_serving_reader() %}
{% set sql %}
grant usage on schema {{ target.schema }} to role api_reader;
grant select on {{ this }} to role api_reader;
{% endset %}
{% do run_query(sql) %}
{% endmacro %}
models:
dbt_geospatial:
serving:
+post-hook: "{{ grant_serving_reader() }}"
Verify the grant is as narrow as intended by checking what the role can actually see:
set role api_reader;
select count(*) from analytics.mart_zones; -- expect: permission denied
select count(*) from serving.serve_zone_features; -- expect: a number
reset role;
4. Give responses a cache identity
A feature service that cannot be cached will be slow no matter how fast the query is. The build stamp carried on every row gives the service an ETag with no extra state.
select
max(built_at) as collection_version,
count(*) as feature_count
from serving.serve_zone_features
where geom && st_makeenvelope($1, $2, $3, $4, 4326)
The service hashes collection_version with the request parameters, returns it as an ETag, and answers a matching If-None-Match with 304 Not Modified. Because built_at changes only when dbt rebuilds the model, caches stay valid for a whole build cycle and invalidate exactly when the data changes.
Verify the stamp advances on rebuild and not otherwise:
dbt run --select serve_zone_features
psql -c "select distinct built_at from serving.serve_zone_features;"
# One value, equal to the run's start time
5. Test the response contract, not just the data
# models/serving/schema.yml
models:
- name: serve_zone_features
description: One GeoJSON-shaped feature per zone, local band. Served read-only to api_reader.
columns:
- name: feature_id
tests: [unique, not_null]
- name: geometry_json
tests:
- not_null
- dbt_utils.expression_is_true:
expression: "geometry_json ? 'type'"
- name: properties
tests:
- dbt_utils.expression_is_true:
expression: "jsonb_typeof(properties) = 'object'"
Add a worst-case response test, since the contract is about the largest plausible request rather than the average one:
-- tests/assert_worst_case_response_size.sql
select
sum(octet_length(geometry_json::text) + octet_length(properties::text)) as bytes
from (
select geometry_json, properties
from {{ ref('serve_zone_features') }}
order by octet_length(geometry_json::text) desc
limit 1000
) worst
having sum(octet_length(geometry_json::text) + octet_length(properties::text)) > 2000000
Configuration reference
| Parameter | Where | Typical value | Note |
|---|---|---|---|
| coordinate precision | ST_AsGeoJSON(geom, n) |
6 | About 11 cm; the cheapest size reduction available |
| feature ceiling | service constant | 1,000–5,000 | Server-side; clamp the caller’s value below it |
| bounding box | required query parameter | — | Without it the endpoint eventually returns the whole table |
built_at |
model column | run_started_at |
Cache identity with no extra state to manage |
| grants | model post-hook | select on serving only |
The API role must not reach marts or intermediate models |
| geometry band | source model filter | local or regional |
Never serve the full-precision mart geometry |
Gotchas & edge cases
ST_AsGeoJSONoutput is a JSON string in some engines and JSON in others. Cast explicitly, or the API returns a quoted string where clients expect an object.propertiesbuilt withjsonb_build_objectsilently drops nulls in some formulations. Decide whether a missing attribute should be absent or explicitly null, and test for it — clients often branch on the difference.- A bounding box crossing the antimeridian is two boxes.
ST_MakeEnvelopewith a minimum longitude greater than the maximum produces an empty result; split the request server-side. - The
bboxmember is optional in GeoJSON but valuable. Clients use it to skip parsing features outside the viewport; computing it once in the model is cheaper than per-request. - Ordering matters for pagination.
LIMITwithout a stableORDER BYreturns arbitrary subsets, so page two can repeat rows from page one.
FAQ
Should the API assemble the FeatureCollection, or should SQL?
Assemble in the API. A single jsonb_agg in SQL is tempting, but it builds the entire collection in the database’s memory before the first byte is sent, which turns a streamable response into a spike. Returning one row per feature lets the service stream and lets the database stop early when a limit is hit.
How do I support "features near me" as well as a viewport?
Add a second query shape using ST_DWithin against a point with a server-clamped radius, ordered by distance and limited. Keep it as a separate endpoint rather than an optional parameter, so each has one index-eligible predicate and one obvious plan.
Is it acceptable to serve directly from the warehouse?
For low request rates, yes — with a bounded query, a narrow grant and a cache in front. For high rates, copy the serving model into a database sized for concurrency; analytical warehouses charge per query and are not built for thousands of small reads per second. Either way the model stays the dbt-built one, so lineage and tests are unchanged.
What should the endpoint return when the limit truncates the result?
The features it has, plus an explicit flag — a "truncated": true member alongside the collection, or a Preference-Applied header. Silent truncation is the failure mode that produces “the map is missing features” bug reports that nobody can reproduce, because it depends on the viewport.
Related
- Serving Spatial Data to Consumers — the serving tier and its budgets.
- Simplifying Geometries for Map Payloads — producing the band this model serves from.
- Implementing Row-Level Security for Geospatial Data — when features must be filtered per caller.
Up: Part of Serving Spatial Data to Consumers.