Skip to main content
dbt advanced Lesson 9 of 10

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: [email protected]
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: [email protected]

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.
AccessWho may ref it
privatemodels in the same group
protectedanything in the same project (the default)
publicany 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.

Frequently Asked Questions

What does dbt docs generate produce?
`catalog.json`, a description of every relation and column read from the warehouse, which combines with `manifest.json` to power a browsable site with the full DAG. `dbt docs serve` hosts it locally; most teams publish it from CI instead.
What is a model contract in dbt?
An enforced promise about a model's columns and types. With `contract: {enforced: true}`, dbt checks the model's output against the declared schema at build time and fails the run if a column is missing, renamed, or has the wrong type.
What is an exposure?
A declaration that something outside dbt — a dashboard, an ML job, a reverse-ETL sync — depends on specific models. It appears in the lineage graph, so you can see which dashboards break before changing a model, and it can be selected with `dbt build --select +exposure:name`.
What are model access levels for?
They mark which models other teams may reference. A `private` model can only be referenced inside its own group, `protected` within the project, and `public` from anywhere. It stops a staging model quietly becoming a load-bearing interface for three other teams.