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+ (exposures and rich meta support).
  • A docs generate step 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.

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

bash
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
Which spatial facts belong in meta and which belong in prose Two columns. The meta column lists machine-checkable facts: grain, geometry column name, storage SRID, geometry type, computation SRID, update cadence and owner. The prose column lists things only a person can express: why this boundary set was chosen, what the zones mean, and known caveats. An arrow shows tests and audits reading only from the meta side. meta — machine-checkable grain · geometry_column · storage_srid geometry_type · area_computation_srid update_cadence · owner a script can assert every one of these description — human judgement why this boundary set and not another what a zone means operationally caveats a consumer must know no script can generate or check these completeness audit reads here

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.

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

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

yaml
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:

bash
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

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

bash
curl -s https://internal-docs.example/dbt-geospatial/manifest.json \
  | python -c "import json,sys; print(json.load(sys.stdin)['metadata']['generated_at'])"
Where the lineage graph ends with and without exposures Two versions of the same graph. Without exposures, the chain runs from source through staging and marts to a serving model and stops there, so the impact of a change appears to end inside the warehouse. With exposures declared, the graph continues to an operations map and a statutory report, each with a named owner, making the true blast radius visible. without exposures — the graph stops at the warehouse edge source staging mart serving …and here the impact analysis stops with exposures — the real blast radius source staging mart serving City Operations Map · mapping team Statutory Area Report · finance The four questions a spatial docs site should answer without anyone being asked Four common questions paired with the documentation element that answers each. Is this in metres is answered by storage and computation SRID in meta. How many rows per zone is answered by the grain field and its matching test. What breaks if I change this column is answered by exposures. Where did this boundary come from is answered by the source authority field and the source declaration. the question people ask what answers it "is this area in metres?" meta.storage_srid + area_computation_srid "is it one row per zone?" meta.grain + the unique test "what breaks if I rename this?" exposures with named owners "where did this boundary come from?" meta.source_authority + the source

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

  • meta is not validated. A typo in storage_srid is 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.yml and in docs blocks can diverge. Pick one location per project; the docs block 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.

Up: Part of Spatial Data Lineage Documentation.