Exposing spatial lineage in dbt docs
This page turns a generic dbt docs site into one that answers the questions a spatial team actually asks: which CRS is this geometry in, what is the grain of this model, which map depends on it, and where did this boundary come from.
When to use this approach
- Analysts keep asking questions the docs could answer. “Is this in metres?” and “which model feeds the map?” are documentation failures, not people failures.
- A spatial model has consumers outside the warehouse. A tile source or a feature API is a dependency that the DAG cannot see unless you declare it.
- An audit or handover is coming. Lineage that lives in someone’s head does not survive either. The documentation practice this extends is in spatial data lineage documentation.
Prerequisites
- dbt 1.5+ (
exposuresand richmetasupport). - A
docs generatestep already in the build, and somewhere to host the output. - A naming convention for geometry columns, so documentation can be checked mechanically.
- The CRS policy decided, since documenting it presumes it exists.
Step-by-step instructions
1. Record the spatial facts in meta, not in prose
Prose descriptions are for humans; meta is for both humans and scripts. Put the facts that must be queryable in meta and explain them in description.
# models/marts/schema.yml
models:
- name: mart_zones
description: >
Service zone boundaries, current version. One row per zone.
meta:
grain: zone_id
geometry_column: geom
storage_srid: 4326
geometry_type: MULTIPOLYGON
area_computation_srid: 3035
source_authority: "City open data portal, boundary release 2026-06"
update_cadence: monthly
owner: platform-data
columns:
- name: geom
description: >
Zone boundary, EPSG:4326, validated and repaired in staging.
Simplified copies for rendering live in the serving schema.
meta:
srid: 4326
validated: true
repaired_in: stg_zones
tests:
- not_null
Verify the metadata is complete across the project by querying the manifest rather than reading YAML:
dbt parse
python - <<'PY'
import json
m = json.load(open('target/manifest.json'))
missing = [n['name'] for n in m['nodes'].values()
if n['resource_type'] == 'model'
and 'geom' in json.dumps(n.get('columns', {}))
and not n.get('meta', {}).get('storage_srid')]
print('models with geometry and no storage_srid:', missing)
PY
2. Declare the consumers as exposures
A tile source and a feature API are real dependencies, and without exposures the DAG ends at the mart as though nothing used it.
# models/exposures.yml
exposures:
- name: city_operations_map
label: City Operations Map
type: application
maturity: high
url: https://ops.example.internal/map
description: >
Live operations map. Renders vector tiles from serve_zone_tiles and
fetches zone attributes from the feature endpoint. Breaks visibly if
either model's column contract changes.
depends_on:
- ref('serve_zone_tiles')
- ref('serve_zone_features')
owner:
name: Mapping Platform
email: mapping@example.internal
- name: statutory_area_report
label: Statutory Area Report
type: analysis
maturity: high
description: >
Quarterly area figures submitted externally. Areas must be computed in
EPSG:3035; a change of computation CRS is a reportable change.
depends_on:
- ref('mart_zone_metrics')
Verify the exposures resolve and appear downstream of the right models:
dbt ls --select +exposure:city_operations_map
# Should list the serving models and everything upstream of them
3. Add a CRS and grain badge to every geometry model
Documentation people do not read is documentation that does not exist. The most-read part of a docs page is the first line of the description, so put the two facts that cause the most incidents there.
models:
- name: mart_zone_metrics
description: >
**Grain:** one row per zone. **Geometry:** EPSG:4326 stored;
areas computed in EPSG:3035 (equal-area).
Zone areas and perimeters for statutory reporting. Areas are not
comparable with figures computed in a UTM zone; see the projection
note in the spatial reference system topic.
Verify the convention is applied everywhere with a docs lint step:
python - <<'PY'
import json, re
m = json.load(open('target/manifest.json'))
bad = [n['name'] for n in m['nodes'].values()
if n['resource_type'] == 'model'
and n.get('meta', {}).get('geometry_column')
and not re.search(r'\*\*Grain:\*\*', n.get('description', ''))]
print('geometry models missing a grain badge:', bad)
PY
4. Generate and publish the docs as part of the build
dbt docs generate --target prod
aws s3 sync target/ s3://internal-docs/dbt-geospatial/ \
--exclude "*" --include "index.html" --include "manifest.json" --include "catalog.json"
A docs site regenerated only when someone remembers is out of date the first time it matters. Generating it in the same job as the build keeps it honest, and the manifest it publishes is the same one the observability models read.
Verify the published docs show the current lineage:
curl -s https://internal-docs.example/dbt-geospatial/manifest.json \
| python -c "import json,sys; print(json.load(sys.stdin)['metadata']['generated_at'])"
Configuration reference
| Element | Where | Purpose |
|---|---|---|
meta.storage_srid |
model meta | The fact most often asked and most often assumed |
meta.grain |
model meta | Makes a fanout bug reviewable before it happens |
meta.geometry_column |
model meta | Lets audits find geometry models mechanically |
exposures |
exposures.yml |
Extends lineage past the warehouse boundary |
| grain badge | description first line | The part people actually read |
dbt docs generate |
build job | Documentation that regenerates itself stays true |
Gotchas & edge cases
metais not validated. A typo instorage_sridis silent, so the audit script in step 1 is what makes the metadata trustworthy rather than decorative.- Exposures do not create dependencies for
run. They appear in lineage and in--select, but dbt will not build an exposure; the freshness of the map is still the map’s own problem. - Column-level docs go stale fastest. Prefer documenting invariants that are also tested, so a description that stops being true fails a build.
- A docs site behind authentication that nobody has is worse than none, because people stop asking questions and start assuming answers.
- Descriptions in
schema.ymland indocsblocks can diverge. Pick one location per project; thedocsblock form is better for anything longer than a few lines.
FAQ
What should go in meta versus a description?
Anything a script should be able to check goes in meta: SRID, grain, geometry column, cadence, owner. Anything requiring judgement goes in the description: why this source, what a zone means, which caveats matter. The test is simple — if you would want to audit it across all models, it belongs in meta.
Are exposures worth the maintenance?
For consumers that break visibly when a model changes, yes — they are the difference between “we changed a column and something broke somewhere” and a named owner to warn beforehand. For casual, exploratory consumers, no; an exposure list that includes every ad-hoc dashboard becomes noise nobody trusts.
How do I keep documentation from drifting?
Document invariants that are tested, and audit meta completeness in CI. A description asserting a grain that a unique test also enforces cannot drift silently; one asserting something untested will.
Can dbt docs replace a GIS metadata catalogue?
For the warehouse layer, largely — it covers lineage, ownership, CRS and grain. It does not cover the things a formal spatial metadata standard carries, such as positional accuracy and collection methodology, which belong with the source authority. Link to that record from the model description rather than restating it.
Related
- Spatial Data Lineage Documentation — the practice this implements.
- Documenting Geometry Columns in dbt YAML — the column-level conventions.
- Serving Spatial Data to Consumers — the consumers exposures make visible.
Up: Part of Spatial Data Lineage Documentation.