Documentation, Contracts, and Lineage
Doc blocks, exposures and persisted comments, then model contracts and access levels that turn a mart into a stable interface other teams can build on.
A project of thirty models is understandable by the person who wrote it. At three hundred, across teams, the constraint stops being SQL and becomes: which of these tables is safe to build on, and what happens if I change this one?
Descriptions that live next to the code
# models/marts/_marts.yml
version: 2
models:
- name: customer_orders
description: |
One row per customer with lifetime order metrics.
Excludes pending orders — see `stg_orders` for the filter. Customers with no
completed orders appear with `order_count = 0` rather than being dropped.
config:
meta:
owner: analytics-eng
maturity: stable
columns:
- name: customer_id
description: '{{ doc("customer_id") }}'
data_tests: [unique, not_null]
- name: lifetime_value
description: Sum of non-pending order amounts, in GBP.
Repeated descriptions belong in a doc block, so the definition of a shared concept lives in one file:
<!-- models/docs.md -->
{% docs customer_id %}
The bookshop's internal customer identifier, assigned at registration and never reused.
Not the same as `account_id` in the billing system — see the mapping table in
`int_customer_accounts` when joining across the two.
{% enddocs %}
dbt docs generate
16:32:08 Running with dbt=1.9.1
16:32:08 Found 8 models, 2 seeds, 1 snapshot, 14 data tests, 1 source, 431 macros
16:32:08
16:32:08 Concurrency: 4 threads (target='dev')
16:32:08
16:32:08 Building catalog
16:32:09 Catalog written to /home/you/bookshop/target/catalog.json
dbt docs serve --port 8080
16:33:15 Serving docs at 8080
16:33:15 To access from your browser, navigate to: http://localhost:8080
16:33:15 Press Ctrl+C to exit.
The site gives you every model’s description, columns, tests, compiled SQL, and an interactive DAG. The DAG is the part people actually use — it answers “what feeds this” faster than any amount of reading.
Pushing descriptions into the warehouse
Docs only help people who visit the docs site. persist_docs copies them into the
warehouse’s own comment fields, where they surface in every BI tool and SQL editor:
# dbt_project.yml
models:
bookshop:
+persist_docs:
relation: true
columns: true
16:38:41 1 of 1 START sql table model main.customer_orders .............. [RUN]
16:38:41 1 of 1 OK created sql table model main.customer_orders ......... [OK in 0.09s]
duckdb bookshop.duckdb -c "select comment from duckdb_tables() where table_name='customer_orders'"
┌────────────────────────────────────────────────────────┐
│ comment │
├────────────────────────────────────────────────────────┤
│ One row per customer with lifetime order metrics. ... │
└────────────────────────────────────────────────────────┘
An analyst hovering over the table in their BI tool now sees the caveat about pending orders. That is a much higher-traffic location than a docs site.
Declaring what depends on you
dbt’s lineage stops at the warehouse boundary — it has no idea a finance dashboard reads
daily_revenue. Exposures fix that:
# models/marts/_exposures.yml
version: 2
exposures:
- name: finance_weekly_revenue
label: Finance — Weekly Revenue
type: dashboard
maturity: high
url: https://bi.example.com/dashboards/17
description: Reviewed in the Monday finance meeting. Do not break before 09:00.
depends_on:
- ref('daily_revenue')
- ref('customer_orders')
owner:
name: Priya Raman
email: finance-analytics@example.com
dbt ls --select +exposure:finance_weekly_revenue
bookshop.staging.stg_customers
bookshop.staging.stg_orders
bookshop.marts.customer_orders
bookshop.marts.daily_revenue
exposure:bookshop.finance_weekly_revenue
Now the question “what do I break if I change stg_orders” has a named answer with an owner
and an email. And the dashboard’s inputs can be built as a unit:
dbt build --select +exposure:finance_weekly_revenue
16:45:22 Found 8 models, 2 seeds, 14 data tests, 1 exposure, 431 macros
16:45:22
16:45:22 1 of 11 START sql view model main.stg_customers ............... [RUN]
...
16:45:23 Done. PASS=11 WARN=0 ERROR=0 SKIP=0 TOTAL=11
Contracts
A description is a promise nobody enforces. A contract is one dbt checks on every run:
models:
- name: customer_orders
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: bigint
constraints:
- type: not_null
- name: full_name
data_type: varchar
- name: lifetime_value
data_type: double
16:50:03 1 of 1 START sql table model main.customer_orders .............. [RUN]
16:50:03 1 of 1 OK created sql table model main.customer_orders ......... [OK in 0.10s]
Now rename lifetime_value to ltv in the SQL and run again:
16:52:17 1 of 1 ERROR creating sql table model main.customer_orders ..... [ERROR in 0.05s]
16:52:17 Compilation Error in model customer_orders (models/marts/customer_orders.sql)
This model has an enforced contract that failed.
Please ensure the name, data_type, and number of columns in your contract match the
columns in your model's definition.
| column_name | definition_type | contract_type | mismatch_reason |
| -------------- | --------------- | ------------- | ------------------- |
| lifetime_value | | DOUBLE | missing in definition |
| ltv | DOUBLE | | missing in contract |
The table was never replaced. A contracted model cannot silently change shape underneath the dashboards reading it — the rename now requires an explicit edit to the contract, which is exactly the conversation you want to force.
Contracts pair with versions when a breaking change is genuinely needed:
models:
- name: customer_orders
latest_version: 2
config:
contract: {enforced: true}
versions:
- v: 1
deprecation_date: 2026-12-31
- v: 2
columns:
- name: lifetime_value_gbp
data_type: double
16:58:40 1 of 2 START sql table model main.customer_orders_v1 ........... [RUN]
16:58:40 2 of 2 START sql table model main.customer_orders_v2 ........... [RUN]
16:58:40 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 TOTAL=2
Both versions build, consumers migrate on their own schedule, and ref('customer_orders')
without a version resolves to latest_version. After the deprecation date, referencing v1
warns:
17:01:12 [WARNING]: Model 'customer_orders.v1' is deprecated as of 2026-12-31.
Please use 'customer_orders.v2' instead.
Access levels
groups:
- name: finance
owner:
name: Analytics Engineering
email: analytics-eng@example.com
models:
- name: stg_orders
config:
group: finance
access: private
- name: customer_orders
config:
group: finance
access: public
Reference the private model from outside its group:
17:05:33 Encountered an error:
Parsing Error
Node model.bookshop.marketing_summary attempted to reference node
model.bookshop.stg_orders, which is not allowed because the referenced node is
private to the finance group.
| Access | Who may ref it |
|---|---|
private | models in the same group |
protected | anything in the same project (the default) |
public | any project, including cross-project refs in dbt Mesh |
The point is not to restrict people. It is that public marks the handful of models you have
committed to keeping stable, and everything else stays free to refactor.
What to document, realistically
Documenting all three hundred models never happens. What pays for itself:
- Every mart — description, grain, and owner. These are what people query.
- Every non-obvious column — anything with a filter, a unit, or a business rule baked in.
- Every source — where it comes from and how often it lands.
- Contracts on the handful of models other teams read. Not on staging, which should stay cheap to change.
A staging model called stg_orders that renames six columns needs no description. Writing
one anyway is how documentation becomes noise nobody reads.
Practice
1. Write a doc block and reference it from two models.
{% docs order_amount %}
Order total in GBP, excluding shipping and after discounts. Refunds appear as separate
negative-amount rows rather than adjusting the original.
{% enddocs %}
- name: amount
description: '{{ doc("order_amount") }}'
17:12:04 Building catalog
17:12:05 Catalog written to /home/you/bookshop/target/catalog.json
Both models now show the same text, and the refund caveat is defined once. That caveat is the kind of thing that otherwise lives only in one person’s head.
2. Add an exposure and select its upstream models.
dbt ls --select +exposure:finance_weekly_revenue --resource-type model
bookshop.staging.stg_customers
bookshop.staging.stg_orders
bookshop.marts.customer_orders
bookshop.marts.daily_revenue
Four models to check before touching anything the dashboard depends on. Without the exposure, that list lives in Slack history.
3. Enforce a contract, then change a column type.
| column_name | definition_type | contract_type | mismatch_reason |
| -------------- | --------------- | ------------- | ---------------------- |
| lifetime_value | DECIMAL(18,2) | DOUBLE | data type mismatch |
Caught at build time, before the table is replaced. A type change that reaches a BI tool unannounced typically surfaces as a broken chart hours later, with no obvious cause.
4. Mark a model private and reference it from another group.
Parsing Error
Node model.bookshop.marketing_summary attempted to reference node
model.bookshop.stg_orders, which is not allowed because the referenced node is
private to the finance group.
A parse-time error, so nothing runs. The useful part is the conversation it triggers: either
the model becomes public deliberately, or the other team gets a mart built for them —
better than discovering the dependency during a refactor.
Next: deployment — environments, Slim CI, and running dbt on a schedule.