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-postgres against PostGIS ≥ 3.3, or dbt-duckdb with 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 generate will 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 hosted catalog.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, and meta.precision so 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.

Spatial lineage graph annotated with SRID and precision changes from source to exposure A left-to-right dbt lineage graph of four nodes connected by ref edges, ending in a BI exposure. The raw source parcels_raw holds SRID 2263 in US survey feet at full source precision. The staging model stg_parcels reprojects with ST_Transform to SRID 4326 and validates geometry, changing the coordinate frame. The intermediate model int_service_areas dissolves parcels with ST_Union, changing the geometry type from polygon to multipolygon while holding SRID 4326. The mart model mart_service_areas runs ST_Simplify with a tolerance of 0.0001 degrees, reducing precision for tiling while keeping SRID 4326. The final exposure is a service-area map dashboard. Each ref edge is labelled with what changed: reproject, dissolve, and simplify. source parcels_raw SRID 2263US survey feetfull precision staging stg_parcels SRID 4326MultiPolygonvalidated intermediate int_service_areas SRID 4326dissolvedfull precision mart mart_service_areas SRID 4326ST_Simplifytol 0.0001 exposure Service-area map reproject ST_Transform dissolve ST_Union simplify ST_Simplify The DAG is free; documentation is what labels every edge with its SRID and precision change 2263 → 4326 at staging · polygon → multipolygon at dissolve · precision dropped at the mart

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:

markdown
<!-- 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:

yaml
# 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.

yaml
# 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.

yaml
# 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.

yaml
# 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.

sql
-- 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 %}
yaml
# 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:

bash
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.

python
# 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

↑ Back to Spatial Data Architecture & Governance

Explore this section