Spatial Data Lineage Documentation
Every spatial transformation quietly rewrites the provenance of a geometry, and almost none of them leave a note behind. A staging model reprojects with ST_Transform and the stored SRID changes from a survey-foot state plane to WGS 84. An intermediate model dissolves parcels with ST_Union and the geometry type flips from polygon to multipolygon. A mart runs ST_Simplify and drops coordinate precision the map will never render. Six months later an analyst asks a reasonable question — “what coordinate system is this served in, and how accurate are these boundaries?” — and the only honest answer is to read the SQL of every model between the source and the dashboard. Spatial lineage documentation closes that gap by making the provenance a first-class, version-controlled artifact of the dbt project rather than tribal knowledge. This page is part of Spatial Data Architecture & Governance, and it covers how to capture geometry lineage so that the SRID, the geometry type, and the precision of every layer are documented where they change, surfaced in the generated catalog, and connected to the downstream maps and BI reports that consume them.
The distinction that matters is between structural lineage — which model reads which — and semantic lineage — what the transformation does to the geometry’s coordinate frame and fidelity. dbt gives you the structural lineage for free: the ref() graph is the DAG, and dbt docs renders it. The semantic layer is the part teams skip, and it is the part that causes silent errors, because a lineage graph that shows stg_parcels → int_service_areas → mart_service_areas without recording that the SRID went 2263 → 4326 or that precision was reduced is a diagram of pipes with no label on what flows through them. The patterns below attach that semantic detail to the structural graph dbt already builds.
Prerequisites checklist
Before documenting lineage, confirm the project has the pieces the catalog is built from:
- dbt-core ≥ 1.6 with a spatial-capable adapter —
dbt-postgresagainst PostGIS ≥ 3.3, ordbt-duckdbwith the spatial extension for local catalog builds. Adapter differences in how geometry types surface in the catalog are compared in choosing the right spatial adapter. - A resolvable DAG.
dbt docs generatewill not render lineage for a project that fails to compile, so the dependency graph must already be acyclic — untangling cycles is covered in spatial model dependency graphs. - A canonical SRID decided for the project, so every layer can be documented as either “holds the canonical SRID” or “an explicit exception”. That policy lives in CRS governance policy.
- Write access to a docs static site (dbt Cloud,
dbt docs serve, or a hostedcatalog.json/manifest.json) so the documentation is reachable by the people who consume the data, not only the engineers who build it. - Convention agreed for geometry metadata keys — this guide uses
meta.srid,meta.geometry_type, andmeta.precisionso the values are machine-readable, not buried in prose.
Architecture context: lineage as annotated DAG
The generated DAG is the skeleton; documentation hangs the coordinate-frame and precision facts on each node so a reader can trace not just where a geometry came from but what happened to it on the way. The diagram below shows the same four-model pipeline dbt would draw, annotated with the SRID and precision changes that occur at each ref() edge.
Read the graph as a contract, not a picture. Between parcels_raw and stg_parcels the SRID changes, so that edge must be documented as the reprojection boundary. Between the intermediate and mart the precision drops, so the mart’s geometry column must carry a note that its coordinates are lossy relative to upstream. When these facts live in the project alongside the models, dbt docs renders them into the same catalog page as the DAG, and the exposure at the end tells a consumer exactly which model — and therefore which SRID and precision — feeds their dashboard.
Configuration walkthrough
Four dbt features carry spatial lineage, and each documents a different part of the graph. Understanding what each is for prevents the common mistake of dumping everything into a model description.
| dbt feature | What it documents | Where it lives |
|---|---|---|
Model description |
The model’s role, layer, and any geometry-wide transformation | schema.yml under models: |
Column description + data_type |
Per-column type, SRID, geometry type, and precision notes | schema.yml under columns: |
docs block + {{ doc() }} |
Reusable prose (CRS conventions, precision policy) referenced from many models | .md files in the project |
exposure |
The downstream map or BI report and the models it depends on | exposures.yml |
The reusable-prose case is the one that scales. A CRS convention explained once in a docs block and referenced from forty models stays consistent; the same sentence pasted into forty descriptions drifts the first time someone edits one copy. Define the block in any .md file the project globs:
<!-- models/docs/spatial_conventions.md -->
{% docs canonical_crs %}
All geometry columns downstream of staging are stored in **EPSG:4326** (WGS 84,
longitude/latitude in decimal degrees). Distances and areas are therefore **not**
metric at rest — reproject to a local projected SRID before any metric operation.
Staging reprojects from source SRIDs with `ST_Transform`; see the model description
for the source frame of each feed.
{% enddocs %}
{% docs mart_precision %}
Geometry in this mart has been reduced with `ST_Simplify` (tolerance 0.0001°,
roughly 11 m at the equator) for map tiling. Do **not** use it for area,
containment, or measurement work — join back to the intermediate layer for
full-precision geometry.
{% enddocs %}
Referencing a block from a description keeps the wording in one place:
# models/marts/_marts.yml
version: 2
models:
- name: mart_service_areas
description: "{{ doc('canonical_crs') }} {{ doc('mart_precision') }}"
The mechanics of writing column-level geometry documentation — the exact data_type, meta, and description keys — are covered step by step in documenting geometry columns in dbt YAML. This page focuses on the pipeline-wide picture those column docs roll up into.
Core implementation
Documenting a model’s geometry transformation
A spatial model’s description should answer three questions a downstream reader will actually ask: what does this layer do to the geometry, what SRID does it emit, and what fidelity does it hold. Capture the machine-readable facts in meta so they can be queried out of manifest.json, and keep the human explanation in description.
# models/staging/_staging.yml
version: 2
models:
- name: stg_parcels
description: >
Parcel boundaries reprojected from the county source (EPSG:2263, US survey
feet) to the canonical {{ doc('canonical_crs') }} Validity is repaired with
ST_MakeValid; single polygons are promoted to MultiPolygon for type uniformity.
meta:
layer: staging
source_srid: 2263
output_srid: 4326
geometry_transform: "ST_Transform + ST_MakeValid"
columns:
- name: geom
description: "Parcel boundary, canonical SRID, repaired and promoted to MultiPolygon."
data_type: geometry(MultiPolygon, 4326)
meta:
srid: 4326
geometry_type: MultiPolygon
precision: full
tests:
- not_null
The data_type: geometry(MultiPolygon, 4326) string is doing real work: it is a typed contract that documents the SRID and geometry type in the one place a schema contract will also enforce them, so the documentation cannot drift from the physical column without failing a build.
Recording precision changes at the mart boundary
The most error-prone transformation to leave undocumented is precision reduction, because the geometry still looks right on a map — it is only wrong when someone measures it. Document the simplification explicitly, both in prose and as a queryable meta value.
# models/marts/_marts.yml
version: 2
models:
- name: mart_service_areas
description: "{{ doc('mart_precision') }}"
meta:
layer: mart
simplify_tolerance_deg: 0.0001
precision: lossy
lossless_source: int_service_areas
columns:
- name: geometry
description: >
Dissolved service-area boundary, simplified for tiling. For measurement,
use int_service_areas which retains full coordinate precision.
data_type: geometry(MultiPolygon, 4326)
meta:
srid: 4326
geometry_type: MultiPolygon
precision: lossy
The lossless_source key gives a consumer a machine-readable pointer back to the last full-precision model in the lineage — a breadcrumb that turns “is this accurate?” from an investigation into a lookup.
Connecting lineage to consumers with exposures
The DAG dbt draws stops at the last model. An exposure extends the lineage past the warehouse boundary to the map or report that actually reads the data, so dbt docs can show which dashboards break if a model changes and a consumer can see exactly which SRID and precision feed their view.
# models/exposures.yml
version: 2
exposures:
- name: service_area_coverage_map
label: "Service-Area Coverage Map"
type: dashboard
maturity: high
url: "{{ env_var('BI_SERVICE_AREA_URL', 'https://bi.internal/service-areas') }}"
description: >
Leaflet tile map of dissolved service areas. Renders mart_service_areas,
which is EPSG:4326 and simplified — presentation only, not for measurement.
depends_on:
- ref('mart_service_areas')
owner:
name: Geospatial Platform
email: geo-platform@example.com
Now dbt docs renders the map as a downstream node of mart_service_areas, and the exposure description restates the SRID-and-precision contract at exactly the point of consumption — where a BI developer, not a data engineer, will read it.
Validation and testing
Documentation that is not tested rots, and spatial documentation rots invisibly because a wrong SRID note reads as plausibly as a right one. Two checks keep the lineage honest.
First, assert that the physical geometry matches its documented SRID and type. If the description says EPSG:4326 but the column holds 2263, the build should fail, not the map.
-- tests/generic/assert_documented_srid.sql
{% test assert_documented_srid(model, column_name, expected_srid) %}
select {{ column_name }}
from {{ model }}
where {{ column_name }} is not null
and ST_SRID({{ column_name }}) <> {{ expected_srid }}
{% endtest %}
# wire the test to the documented SRID
columns:
- name: geom
data_type: geometry(MultiPolygon, 4326)
tests:
- assert_documented_srid:
expected_srid: 4326
Second, enforce that every geometry column is actually documented, so lineage gaps surface in CI rather than in a support ticket. dbt’s built-in dbt project evaluate package (or a custom query over manifest.json) can flag any column named geom/geometry whose description is empty. Run it as part of the build:
dbt docs generate # builds manifest.json + catalog.json
dbt run-operation check_geometry_docs # custom op: fail if a geom column lacks a description
Confirming that the documented types match reality is a specialization of the SRID-consistency discipline in detecting SRID mismatches with dbt tests; documentation tests and governance tests share the same underlying ST_SRID assertion, they just fail for different reasons.
Advanced patterns
Programmatic lineage extraction. manifest.json and catalog.json are structured artifacts, so the geometry lineage you documented in meta is queryable. A short script can walk the manifest, follow depends_on edges, and emit a per-column report of how SRID and precision change along each path — a “geometry passport” for any mart column. Because the source of truth is the meta keys, the report never disagrees with the catalog.
# scripts/geometry_lineage.py — sketch
import json
manifest = json.load(open("target/manifest.json"))
for uid, node in manifest["nodes"].items():
for col, meta in node.get("columns", {}).items():
m = meta.get("meta", {})
if "srid" in m:
print(f"{node['name']}.{col}: SRID {m['srid']}, "
f"{m.get('geometry_type')}, precision={m.get('precision')}")
Layer-scoped documentation policy. Rather than documenting each model by hand, set defaults per layer in dbt_project.yml so every staging model inherits a “reprojects to canonical SRID” note and every mart inherits a “may be simplified” note, then override only the exceptions. This turns lineage documentation into a governance default rather than a per-model chore, mirroring how the canonical projection itself is enforced in enforcing a canonical SRID across dbt models.
Versioning the lineage narrative. When a model’s SRID or precision policy changes, the description and meta change with it in the same commit, so git blame on the schema YAML is a dated history of every provenance decision. Pairing this with versioning spatial schemas in dbt means a breaking change to a geometry column and its documentation move together through review, and neither can ship without the other.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
dbt docs shows the DAG but no descriptions |
description keys empty, or the .md docs file is outside the configured docs-paths |
Add descriptions; confirm the docs .md is under a globbed path so {{ doc() }} resolves |
{{ doc('name') }} raises “documentation block not found” |
Block name typo, or the .md file not committed / not in the project |
Match the {% docs name %} identifier exactly; ensure the file ships with the project |
| Documented SRID disagrees with the data | Description hand-edited but the model’s ST_Transform target changed |
Add assert_documented_srid; tie the note to the typed data_type contract so drift fails the build |
| Exposure not appearing downstream of its model | depends_on uses a string instead of ref(), or the model name is misspelled |
Use ref('model_name') in depends_on; rebuild the manifest with dbt docs generate |
| Precision loss invisible to consumers | Simplification applied in SQL but never recorded in meta/description |
Record simplify_tolerance and precision: lossy in the mart’s column meta; point to the lossless source |
Catalog shows geometry columns as opaque USER-DEFINED |
The warehouse reports the geometry type generically | Document the concrete type in data_type: geometry(Type, SRID) so the catalog carries the real contract |
Related
- Documenting Geometry Columns in dbt YAML — the step-by-step for column-level
data_type, SRID, and precision docs. - Spatial Model Dependency Graphs — the structural DAG that lineage documentation annotates.
- CRS Governance Policy — the canonical-SRID rules each layer’s documentation asserts against.
- Versioning Spatial Schemas in dbt — move a geometry column change and its documentation through review together.
- Spatial Data Architecture & Governance — CRS policy, schema versioning, source freshness, and security scoping.