Documentation: Descriptions, Doc Blocks, and dbt Docs
Model and column descriptions in schema.yml, reusable doc blocks with the doc() function, generating and serving the dbt docs site, the auto-generated DAG lineage graph, meta fields and tags, and documentation as a team habit instead of an afterthought.
dbt Treats Documentation as Compiled Output, Not a Side Note
In most data warehouses, documentation lives somewhere other than the code that produces the data — a wiki page, a spreadsheet, a Confluence doc someone wrote once during onboarding and nobody has updated since. The moment a model changes, that external documentation is already stale, and nothing forces anyone to go update it. Six months later, a new analyst reads the wiki page, trusts it, and builds a report on assumptions that stopped being true three model changes ago.
dbt takes a different approach. Descriptions live in the same YAML files that already configure your tests, right next to the model and column they describe. Documentation is not a separate artifact maintained on a separate schedule — it is compiled, alongside your models and the dependency graph itself, into a browsable website with dbt docs generate. Because the descriptions sit in version control next to the SQL, a pull request that changes a model's logic is the same pull request that should update its description — the two changes travel together instead of drifting apart.
What dbt's documentation system actually produces: a static website with one page per model and per source, each showing its compiled SQL, its column list with descriptions, its tests, and its exact position in the dependency graph — plus an interactive, zoomable diagram of the entire DAG that is generated automatically from your project's actual ref()and source() calls, not drawn by hand.
This module covers three layers of dbt documentation: descriptions written directly inschema.yml at the model and column level (Part 02), doc blocks for longer, reusable prose that would be unwieldy to repeat inline (Part 03), and the generated docs site itself — including the DAG visualization, which is arguably the single most valuable piece of automatically generated documentation in the entire tool (Part 04 and Part 05).
Model-Level and Column-Level Descriptions
The simplest and most common form of dbt documentation is a plain description: field added directly under a model or one of its columns in schema.yml — the exact same file where you already declare tests. There is no separate documentation file to maintain for this level of detail; you are adding one more key to YAML you are likely editing anyway.
version: 2
models:
- name: fct_orders
description: >
One row per completed customer order. Grain is order_id. Built from
stg_orders joined to stg_order_items (aggregated to order level) and
dim_customers. Refreshed hourly via an incremental materialization —
see the Incremental Models module for the exact strategy.
columns:
- name: order_id
description: Primary key. Unique identifier for a single customer order.
tests:
- unique
- not_null
- name: customer_id
description: Foreign key to dim_customers.customer_id.
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: order_total_cents
description: >
Sum of all line item prices in cents, after discounts and before
tax. Does not include shipping. Always a positive integer —
refunded orders are represented as separate negative-value orders,
not as a mutated original row.
- name: order_status
description: >
Current lifecycle state of the order. One of: placed, packed,
shipped, delivered, cancelled, refunded. See the accepted_values
test on this column for the authoritative list.Descriptions accept plain text or, using the YAML block scalar > (folds newlines into spaces) or | (preserves newlines), multi-line prose. They also accept Markdown — a description can include a bullet list, a bolded term, or an inline code span, and the generated docs site renders it as formatted HTML rather than showing raw asterisks and backticks.
Sources get descriptions too
Documentation is not limited to models you build. Sources — the raw tables dbt reads viasource() — take the same description: field at both the source and table level, which matters because sources are frequently the least-understood part of a project: a raw table named orders_v2 in a production application database tells a new analyst nothing about what changed between v1 and v2 without a description explaining it.
version: 2
sources:
- name: shopify
description: Raw tables replicated from the Shopify production database via Fivetran, every 15 minutes.
tables:
- name: orders
description: >
Raw orders table. One row per order as it existed at the last
sync. Note: this table is mutated in place by the source system,
so historical states of an order are not preserved here — see
stg_orders for the cleaned, deduplicated version this project
actually builds on.
columns:
- name: id
description: Shopify's internal order ID. Renamed to order_id in stg_orders.description: field is purely metadata. It has zero effect on compiled SQL, on materialization, or on test execution. This means it is completely safe to add, edit, or remove at any time without touching the model's actual behavior — which is exactly why there is no excuse for leaving it blank. Adding a description carries none of the risk of changing logic.Doc Blocks — Reusable, Long-Form Documentation
Inline description: fields work well for a sentence or a short paragraph. They break down for two situations: prose long enough to clutter the YAML file, and prose that needs to be shared identically across many models or columns. Repeating the same three-paragraph explanation of what customer_id means, verbatim, on twelve different columns across eight different models is exactly the kind of duplication that goes stale — someone updates it on four of the twelve and the other eight are now subtly wrong.
A doc block solves this. It is a named, reusable chunk of Markdown, written once in a.md file anywhere inside your models/ directory, and referenced from any description: field using the {{ doc('block_name') }}function. dbt resolves the reference at compile time and substitutes the full text.
{% docs customer_id %}
The unique identifier for a customer in the core customer table
(dim_customers). This ID is stable for the customer's entire lifetime,
even if their email address, name, or shipping address changes.
Do not confuse this with the source system's own customer identifier —
Shopify, Stripe, and the support ticketing system each have their own
internal customer IDs. dim_customers.customer_id is dbt's own generated
surrogate key, built as a hash of the Shopify customer ID during staging,
so that a future migration away from Shopify would not require rebuilding
every downstream fact table's join key from scratch.
{% enddocs %}
{% docs order_status_lifecycle %}
Orders move through a fixed set of lifecycle states, in this order:
- **placed** — the order was submitted and payment authorized.
- **packed** — warehouse staff have picked and boxed the items.
- **shipped** — the carrier has taken possession of the package.
- **delivered** — the carrier's tracking API confirmed delivery.
- **cancelled** — the customer or support cancelled before shipment.
- **refunded** — a completed order was later refunded, in part or in full.
An order can move from placed directly to cancelled, skipping packed and
shipped entirely. An order cannot move backward — a delivered order that
is refunded gets order_status = refunded, it does not revert to delivered.
{% enddocs %}Any model's schema.yml can now reference either block by name, and the full text above is substituted in wherever the reference appears — in the compiled documentation site, not in the SQL itself, since doc blocks never touch compiled model logic.
models:
- name: fct_orders
columns:
- name: customer_id
description: '{{ doc("customer_id") }}'
- name: order_status
description: '{{ doc("order_status_lifecycle") }}'
- name: dim_customers
columns:
- name: customer_id
description: '{{ doc("customer_id") }}'
tests:
- unique
- not_nullBoth fct_orders.customer_id and dim_customers.customer_id now show the identical, complete explanation on the docs site — updated in exactly one place, the.md file, the next time the definition needs to change. This is the same DRY principle Part 04 of the next module applies to SQL logic via macros, applied here to documentation text instead.
| Approach | Where it lives | Best for |
|---|---|---|
| Inline description: | Directly in schema.yml, next to the field | A short, one-off sentence specific to a single model or column |
| Doc block ({% docs %}) | A separate .md file, referenced via doc() | Long-form prose reused across multiple models or columns, or text long enough to clutter YAML |
{% docs name %} block shares one flat namespace across the entire project, regardless of which .md file it is defined in. Two blocks named customer_idin two different files is a compile error, not a silent override — a useful safety net, but it also means a naming convention (prefixing by domain, e.g. orders_status_lifecycle) is worth adopting early in a growing project.dbt docs generate and dbt docs serve
Writing descriptions and doc blocks is only half the story — they have to be compiled into something browsable. dbt docs generate is the command that does this. It runs through every model, source, seed, and macro in the project, resolves every{{ doc() }} reference, pulls in test configuration, and writes out a static documentation website as JSON and HTML artifacts inside the target/ directory.
$ dbt docs generate
Running with dbt=1.8.0
Found 42 models, 6 sources, 18 tests, 3 seeds, 2 macros
Concurrency: 4 threads (target='dev')
Generating catalog.json
Catalog written to target/catalog.json
Generating manifest.json
Manifest written to target/manifest.json
$ dbt docs serve
Serving docs at 0.0.0.0:8080
To access from your browser, navigate to: http://localhost:8080dbt docs generate produces two key artifacts. manifest.json is the project's full compiled state — every model's SQL, its config, its columns, its description, and crucially its dependency edges (which models ref() or source() which others). catalog.json adds the actual warehouse metadata — the real column types and row counts dbt gets back by querying information_schema in your warehouse. Together they are what the docs website renders. dbt docs serve then starts a small local web server, purely for convenience during local development — most teams instead publish the generated site somewhere persistent and shared, since a docs site only useful on one engineer's laptop defeats the purpose of team-wide documentation.
Hosting the docs site for the whole team
A locally served docs site disappears the moment you close the terminal. In practice, teams generate the docs site as part of their production dbt job (on dbt Cloud, this is usually a checkbox on the production job; on self-hosted orchestration, it is one more step in the same CI/CD pipeline that runs dbt build) and publish the resulting static files somewhere durable — an internal web server, an S3 bucket served through CloudFront, or dbt Cloud's own built-in hosted docs URL. The goal is that any analyst on the team can open one bookmarked link and see documentation that reflects the state of production, not whatever was on someone's laptop the last time they happened to run dbt docs serve.
manifest.json is compiled from the project's actual current state, docs go stale the instant a model changes without a corresponding dbt docs generate. The reliable pattern is to run dbt docs generate as a step in the same CI/CD job that deploys model changes to production — so the published docs site and the deployed models are always the same commit, never out of sync by however long it has been since the last scheduled docs build.The DAG Visualization — dbt's Most Valuable Documentation Feature
Every dbt docs site includes an interactive, zoomable graph showing every model, source, and seed as a node, connected by arrows representing dependencies. This is not a diagram someone drew in a whiteboarding tool and uploaded — it is generated directly from manifest.json's dependency edges, which are themselves derived from the actual ref() andsource() calls inside your compiled SQL.
-- models/marts/fct_orders.sql
select
o.order_id,
o.customer_id,
c.customer_segment,
sum(oi.line_total_cents) as order_total_cents
from {{ ref('stg_orders') }} o
join {{ ref('dim_customers') }} c on o.customer_id = c.customer_id
join {{ ref('stg_order_items') }} oi on o.order_id = oi.order_id
group by 1, 2, 3
-- dbt parses these three ref() calls at compile time and records:
-- fct_orders depends on stg_orders
-- fct_orders depends on dim_customers
-- fct_orders depends on stg_order_items
--
-- The lineage graph draws exactly these three arrows into fct_orders.
-- There is no separate diagram to keep in sync — the arrows ARE the ref() calls.This is the single most important property of the lineage graph: it is always accurate. A hand-maintained architecture diagram in Lucidchart or a wiki page is correct on the day it is drawn and starts drifting from reality the moment anyone adds a join, removes a dependency, or builds a new model — and nothing forces the diagram to be updated when that happens. dbt's graph cannot drift, because it is not drawn independently of the code; it is a direct rendering of the code's own dependency declarations. If the graph is wrong, the models are wrong in exactly the same way, because they are the same source of truth.
| Property | Hand-drawn architecture diagram | dbt's generated lineage graph |
|---|---|---|
| Source of truth | A person's memory of how the pipeline works, at the time it was drawn | Actual ref() and source() calls compiled from the real project |
| Goes stale when a model changes? | Yes — nothing forces an update, drift is silent and invisible | No — regenerating docs after any change reflects the new dependencies automatically |
| Shows the real current state? | Only if someone remembered to update it recently | Always, as of the last dbt docs generate |
| Effort to keep accurate | Manual, ongoing, easy to skip under deadline pressure | Zero — accuracy is a byproduct of the code itself, not separate effort |
Beyond accuracy, the graph is genuinely useful for day-to-day work. Clicking a node highlights its full upstream and downstream lineage, instantly answering "what does this model actually depend on?" and "what would break if I changed this model's schema?" — questions that, without the graph, require manually grepping through every SQL file in the project for ref()calls. New team members use the graph to build a mental map of an unfamiliar project far faster than reading SQL files one at a time in an arbitrary order.
{{ ref() }}and {{ source() }} rather than hardcoded table names. A model that writesfrom analytics.stg_orders directly, instead of from {{ ref('stg_orders') }}, is invisible to the dependency graph — dbt has no way to know that dependency exists, because it only ever looks like a plain string in the compiled SQL, and that model's lineage will simply be missing an arrow it should have.Meta Fields and Tags — Organizing Models Beyond Description Text
Descriptions and doc blocks explain what a model means to a human reading the docs site. Meta fields and tags serve a different purpose: they attach structured, machine-usable metadata to a model that other tooling — including dbt's own selector syntax — can filter and query on.
meta: — structured ownership and classification data
meta: accepts an arbitrary key-value dictionary, most commonly used for ownership (which team or person is responsible for this model), a Slack channel to page when something breaks, or a data domain classification. Unlike free-text descriptions, meta values are structured enough that other systems — an internal ownership dashboard, an alerting tool that reads manifest.json — can programmatically look them up.
models:
- name: fct_orders
description: One row per completed customer order.
meta:
owner: data-platform-team
slack_channel: '#data-platform-alerts'
domain: commerce
contains_pii: false
- name: dim_customers
description: One row per customer, current attributes only.
meta:
owner: data-platform-team
slack_channel: '#data-platform-alerts'
domain: commerce
contains_pii: true
pii_fields: [email, shipping_address, phone_number]A team-wide convention around meta.owner pays off the moment a project grows past one team. When fct_orders starts failing its unique test at 3 AM, an on-call engineer who has never seen this model before can open its docs page, readmeta.owner and meta.slack_channel, and know exactly who to page — instead of guessing from a commit history or asking around in a general channel.
tags: — flexible grouping for selection and filtering
tags: is a simpler, flatter mechanism — a list of short labels attached to a model, most commonly used to group models for selective builds. Unlike meta, tags are directly usable in dbt's command-line selector syntax, so they double as an operational grouping tool, not just documentation.
models:
- name: fct_orders
tags: ['finance', 'hourly']
- name: fct_marketing_attribution
tags: ['marketing', 'daily']
- name: dim_customers
tags: ['core', 'hourly']# Run only the models tagged 'hourly' — for example, from an hourly Airflow DAG
$ dbt build --select tag:hourly
# Run only 'finance' models — for example, triggered by a finance team's own schedule
$ dbt build --select tag:finance
# Combine a tag with the graph operator to build a tagged model plus its downstream dependents
$ dbt build --select tag:core+| Mechanism | Shape | Used for |
|---|---|---|
| meta: | Arbitrary key-value dictionary | Ownership, alerting contacts, PII classification, domain — structured facts about a model |
| tags: | Flat list of short string labels | Grouping models for selective dbt run/build commands, e.g. by schedule frequency or by team |
meta is schemaless — dbt does not enforce which keys exist. Left ungoverned, one model ends up with owner and another with team meaning the same thing, and no tool can reliably query across the whole project. Agree on a small, documented set of standard meta keys (owner, slack_channel, domain, contains_pii, at minimum) as a team convention before the project has fifty models each with a slightly different vocabulary.Documentation Debt Accumulates Faster Than Most Teams Expect
A project with ten models and one author rarely needs much documentation — the author holds the context in their head, and anyone with a question just asks them directly. That same project at two hundred models and six contributors is a completely different situation. No single person holds the full context anymore, the original author of a given model may have left the team, and "just ask" stops being a viable strategy at exactly the scale where documentation matters most.
The failure mode is rarely a single dramatic incident. It is a slow accumulation: a new model ships without a description because the deadline was tight and "I'll add it later." Six more models ship the same way over the next quarter. A new hire joins, opens the docs site expecting to understand what fct_subscription_events means, finds an empty description field, and has to either guess from the SQL, ping someone on Slack and wait for a reply, or — the quietly dangerous option — build on top of an assumption about the model that turns out to be wrong. Multiply that by every undocumented model in a two-hundred-model project and the team is now spending a meaningful fraction of every week re-deriving context that a two-sentence description would have made instantly available.
The concrete cost, stated plainly: in a growing project, every undocumented model becomes a recurring tax paid by whoever touches it next — read the SQL from scratch, trace ref() calls by hand to reconstruct what a hand-drawn diagram or a two-line description would have said instantly, or interrupt a teammate who happens to remember. That tax is paid repeatedly, by different people, for as long as the model exists undocumented. A five minute description written once, by the person who has the most context — right when the model is built — is paid once.
The practical fix is not a documentation sprint every few months — those inevitably fall behind again within weeks. It is making descriptions part of the same pull request that introduces or changes a model, the same way tests are expected in that PR. Some teams enforce this with a CI check that fails a build if a new model has no description: field at all; others rely on code review convention. Either way, the goal is the same: documentation debt is cheapest to pay off at the moment the model is written, when the author still has full context in their head, and most expensive to pay off months later, when someone else has to reconstruct that context from scratch.
Fully Documenting fct_subscriptions From Scratch
Putting every piece of this module together: a mart-layer model, fully documented, with a model-level description, column-level descriptions, a reused doc block from a related staging model, and a meta ownership tag.
{% docs subscription_id %}
Surrogate key for a subscription, generated during staging as a hash of
the billing provider's own subscription ID and the billing provider name
(stripe, chargebee). Stable for the subscription's entire lifetime,
including plan upgrades, downgrades, and pauses — a plan change does not
create a new subscription_id, it updates the existing row's plan_name and
mrr_cents.
{% enddocs %}version: 2
models:
- name: fct_subscriptions
description: >
One row per active or historical subscription. Grain is
subscription_id. Combines stg_stripe_subscriptions and
stg_chargebee_subscriptions into one unified subscription model,
since the company migrated billing providers mid-year and both
still have active subscriptions. Refreshed hourly, incremental on
updated_at — see the Incremental Models module for the exact
merge strategy used here.
meta:
owner: billing-team
slack_channel: '#billing-data-alerts'
domain: revenue
contains_pii: false
tags: ['billing', 'hourly']
columns:
- name: subscription_id
description: '{{ doc("subscription_id") }}'
tests:
- unique
- not_null
- name: customer_id
description: '{{ doc("customer_id") }}'
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: billing_provider
description: >
Which billing system this subscription originates from. One of:
stripe (legacy, pre-migration subscriptions still on Stripe
billing), chargebee (all subscriptions created after the
migration date). Both are unified into this single model so
downstream revenue reporting does not need to know which
provider any given subscription came from.
tests:
- accepted_values:
values: ['stripe', 'chargebee']
- name: plan_name
description: Current subscription plan tier — starter, growth, or enterprise.
- name: mrr_cents
description: >
Monthly recurring revenue attributable to this subscription, in
cents, at its current plan tier. Annual plans are normalized to
a monthly figure by dividing by 12 — see the mrr_normalization
macro covered in the next module for exactly how this
normalization is computed.
- name: subscription_status
description: Current lifecycle state — trialing, active, past_due, cancelled.
tests:
- accepted_values:
values: ['trialing', 'active', 'past_due', 'cancelled']Notice what each piece contributes. The model-level description tells a reader what the grain is and why two billing providers are unified into one model — context that isn't visible from the SQL alone, since the SQL itself just contains a union all with no explanation of why it exists. The meta block tells an on-call engineer who to page. The reusedsubscription_id and customer_id doc blocks stay identical to their definitions elsewhere in the project, so this model's docs never silently drift fromdim_customers's explanation of the same concept. And every column with a non-obvious meaning — billing_provider, mrr_cents — gets enough inline description that a new analyst reading the generated docs site, not the SQL, could still correctly write a query against this table without asking anyone a single question.
dbt_meta_testing or a custom macro to assert, as part of CI, that every model in the mart layer has a non-empty description and every column has at least one test. Treating documentation coverage as a checkable property — the same way test coverage is checkable — is a strong lever against the slow accumulation described in Part 07.exposures: — Declaring What Actually Consumes a Model's Output
Everything so far in this module documents the project's own internals — models, sources, and the dependencies between them. The lineage graph in Part 05 is complete on the input side: it shows every upstream table a model reads from. It says nothing at all about the output side — what actually consumes fct_orders once dbt is done building it. A Looker dashboard, a Python notebook a data scientist runs weekly, a reverse-ETL sync pushing a model into Salesforce — none of that lives inside the dbt project's own DAG, because none of it is built by dbt. Without a way to declare it, that entire downstream half of the picture is invisible from inside the project itself.
An exposures: block in a YAML file closes exactly this gap. It declares a downstream consumer — a dashboard, a notebook, an application, an ML feature pipeline — as a first-class node in the project, explicitly listing which models it depends on via depends_on. Once declared, that consumer appears in the lineage graph as a terminal node, and the same selector syntax used everywhere else in dbt can target it directly.
version: 2
exposures:
- name: executive_revenue_dashboard
label: Executive Revenue Dashboard
type: dashboard
maturity: high
url: https://mycompany.looker.com/dashboards/482
description: >
Weekly executive dashboard showing revenue, order volume, and churn
trends. Reviewed live in the Monday leadership meeting — treat any
breaking change to its underlying models as release-blocking, not
a routine schema update.
depends_on:
- ref('fct_orders')
- ref('fct_subscriptions')
- ref('dim_customers')
owner:
name: Data Platform Team
email: data-platform@mycompany.com
- name: churn_prediction_notebook
label: Churn Prediction Feature Notebook
type: ml
maturity: medium
description: >
A data scientist's weekly notebook that pulls fct_subscriptions and
dim_customers to build features for a churn prediction model. Not
a formal pipeline yet -- run manually, but its output does inform
real retention-campaign targeting decisions.
depends_on:
- ref('fct_subscriptions')
- ref('dim_customers')
owner:
name: Data Science Team
email: data-science@mycompany.comThe type field (dashboard, notebook, application,ml, or analysis) and maturity (low,medium, high) are pure metadata — they do not change dbt's behavior, but they render on the docs site and let a team triage which exposures are genuinely critical versus exploratory. maturity: high on the executive dashboard above is a signal to future contributors: breaking a model this exposure depends on is not a routine change to wave through in review.
Validating an exposure's full dependency chain before a release
Because an exposure's depends_on list is ordinary ref() syntax, it participates in dbt's graph selectors exactly like a model would. This makes it possible to validate, before merging a change, that every model a specific dashboard actually depends on still builds and passes its tests — not just the model you directly edited, but its entire upstream chain, scoped precisely to what that one dashboard needs.
# Rebuild and test only the models feeding executive_revenue_dashboard,
# following every upstream dependency back to raw sources:
dbt build --select +exposure:executive_revenue_dashboard
# Useful specifically in CI on a PR that touches a shared upstream model
# (e.g. stg_orders) -- this confirms every exposure that transitively
# depends on it still builds cleanly, without rebuilding the entire
# project.This is a materially different and more targeted check than Part 04's slim-CI-style "did anything break" validation — +exposure:name answers a specific, business-framed question: "if I ship this change, does the dashboard the CEO looks at every Monday still work?" rather than the more generic "did any test anywhere fail." Teams with a small number of genuinely high-stakes, well-known downstream consumers get outsized value from declaring exactly those as exposures, even if they don't bother declaring every minor internal analysis someone ran once.
| Without exposures | With exposures declared |
|---|---|
| The lineage graph ends at the last dbt model — what actually consumes it is invisible to the project. | The graph extends one more hop to show real downstream consumers as named, owned nodes. |
| A model change's "blast radius" is whatever a person happens to remember or manually check. | +exposure:name selects exactly the models a specific downstream consumer needs, mechanically, not from memory. |
| No structured way to flag "this specific dashboard is business-critical, be careful." | maturity and description on the exposure itself carry that signal directly into the docs site and PR review. |
| A downstream owner is tracked in a spreadsheet or someone's memory, if at all. | owner.name / owner.email lives in the same version-controlled YAML as everything else the project documents. |
executive_revenue_dashboard as an exposure does not mean dbt refreshes the Looker dashboard, runs the notebook, or has any operational control over it whatsoever. An exposure is purely a documentation and dependency-tracking declaration — it tells dbt (and anyone reading the docs site) that this consumer exists and what it depends on, so the project's own picture of its blast radius is complete, without dbt taking on any responsibility for the consumer's own execution.Where the Generated Site Actually Lives, and Who Can See It
Part 04 established that dbt docs serve is a local-only convenience and that a real team publishes the generated site somewhere durable instead. What that actually looks like in practice varies more than it might seem, and the choice carries real tradeoffs around cost, maintenance burden, and — critically, since a docs site can expose column names, business logic, and sometimes sensitive metadata — who is allowed to view it at all.
| Hosting option | What it involves | Access control |
|---|---|---|
| dbt Cloud's built-in docs hosting | Enabled with one setting on a dbt Cloud job; dbt Cloud serves the generated site itself, no separate infrastructure to run. | Inherits dbt Cloud's own project permissions — anyone with access to the dbt Cloud project can view docs; no separate access system to configure. |
| Static hosting: S3 + CloudFront (or GCS + Cloud CDN) | The CI/CD job that runs dbt docs generate uploads target/ to a bucket after every production deploy; a CDN serves it as a plain static site. | Bucket and distribution can be locked to a VPN, an IP allowlist, or fronted with an auth proxy (e.g. an OAuth-gated CloudFront function) — full control, but you build and maintain that layer yourself. |
| An internal web server (nginx serving target/ directly) | Simplest self-hosted option for a team already running internal infrastructure — copy the generated static files to a directory nginx serves. | Whatever the internal network and any auth already in front of that server provides — often just "must be on the company VPN." |
| A generic static-site host (Netlify, Vercel, GitHub Pages) | Convenient for a small team with no existing cloud infrastructure; a CI step deploys target/ on every merge to main. | Varies by provider — some offer password-gating or SSO on paid tiers; a public GitHub Pages site is genuinely public unless the repo itself is private. |
The access-control question deserves more attention than it usually gets, because a compiled docs site is not harmless to expose broadly. It typically includes every model's full compiled SQL, every column name and description (which can include business logic, internal terminology, or hints about how revenue or fraud detection works), and — if meta.contains_pii and similar fields are used per Part 06 — an explicit map of exactly which tables and columns carry sensitive data. That last part is genuinely double-edged: it is extremely useful for a team's own governance work, and exactly the kind of map you do not want reachable by anyone outside the company.
# .github/workflows/deploy.yml (illustrative) -- runs only after
# dbt build succeeds against production in the same job
- name: Generate docs
run: dbt docs generate --target prod
- name: Publish docs to S3
run: aws s3 sync target/ s3://mycompany-dbt-docs/ --delete
- name: Invalidate CDN cache so the new docs are served immediately
run: aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*"
# The S3 bucket and CloudFront distribution are themselves configured,
# outside of this pipeline, to require either VPN-only access or an
# authentication layer in front of the distribution -- dbt has no
# involvement in or awareness of that access-control layer at all.A useful default posture, regardless of which hosting option a team picks: never rely on "obscurity" (an unlisted URL nobody happens to have shared) as the actual access control. An unlisted static site URL is trivially discoverable — through browser history, a shared Slack link, or a search engine that happened to crawl it — so genuine access control means an actual authentication or network boundary in front of the site, not merely the hope that nobody stumbles onto the link.
A quick decision guide
| Team situation | Reasonable default choice |
|---|---|
| Already on dbt Cloud, no unusual access requirement | dbt Cloud's built-in docs hosting — lowest effort, and permissions already match who has dbt Cloud project access. |
| Self-hosted dbt, existing cloud infrastructure (AWS/GCP), sensitive data in the schema | S3/GCS + CDN behind an internal auth layer — more setup, but access control matches internal security requirements. |
| Small team, no existing cloud infrastructure at all, low sensitivity in what docs expose | A generic static-site host with password gating on a paid tier — fastest to stand up without new cloud accounts. |
| Regulated data, strict need-to-know on which columns are PII | Self-hosted behind VPN or SSO, never a public static host regardless of how convenient it looks. |
Whichever option a team picks, the one universal requirement is that dbt docs generateruns as an automated step immediately after every successful production deploy, not as a manual, occasionally-remembered task — a stale docs site that silently drifted from what production actually runs is arguably worse than no docs site at all, since it actively misleads a reader who reasonably assumes it reflects current reality.
Five Misconceptions About dbt Documentation
Three Ways Real Teams Have Used dbt Documentation to Prevent Incidents
An analyst at Squarespace is asked to build a churn dashboard and starts fromfct_subscriptions, assuming subscription_status = 'cancelled'means the customer left. The model's docs page — specifically a description someone wrote months earlier while implementing a grace-period feature — explains that a cancelled subscription still bills through the end of its current period and does not represent an immediate churn event; the actual churn flag lives on a separate churned_at column set only once the grace period expires.
Reading that one paragraph on the docs page, before writing a single line of SQL, avoids shipping a churn dashboard that would have overcounted churn by roughly the length of every customer's grace period — a bug that historically took weeks to notice through a mismatch against finance's own churn numbers, before this model had a documented explanation of its own status field.
A payroll calculation model starts failing its tests during an overnight run at Rippling. The on-call data engineer, who has never touched this specific model, has no idea who normally owns it — the person who built it works on a different team entirely. Opening the model's docs page shows meta.owner: payroll-eng and meta.slack_channel: '#payroll-eng-oncall', set months earlier when the model was first built.
The on-call engineer pages the right channel within two minutes of the failure instead of posting in a general data-team channel and waiting for someone to recognize the model name — a direct payoff of the meta.owner convention this team adopted specifically because their dbt project had grown past the point where every engineer recognized every model on sight.
A newly hired analytics engineer at Gusto is handed a two-hundred-model dbt project in their first week, with no single teammate available to walk them through the whole thing in detail. Instead of reading SQL files in an arbitrary order, they open the dbt docs site's lineage graph, find fct_payroll_runs — the model they've been asked to modify — and click it to highlight its full upstream dependency chain back to raw sources.
Within the graph they can see exactly which staging models feed the model they need to change, and reading each one's description along the way builds a working mental model of the payroll domain in an afternoon — a task that, without accurate, generated lineage, would have meant grepping through dozens of SQL files by hand trying to reconstruct which model depended on which, with no guarantee of finding every dependency.
5 Interview Questions — With Complete Answers
Five Mistakes Teams Make Documenting dbt Projects
dbt Documentation Errors — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Descriptions live directly in schema.yml, right next to the tests already configured for the same model or column — documentation is compiled from version-controlled YAML, not maintained in a separate external system.
- ✓Doc blocks ({% docs name %}...{% enddocs %} in a .md file, referenced via {{ doc('name') }}) let long-form or repeated prose be written once and reused across many models and columns without drifting out of sync.
- ✓dbt docs generate compiles manifest.json (project structure and dependencies) and catalog.json (real warehouse metadata) into a static docs site; dbt docs serve is for local viewing only — publish the generated site as part of your production deploy pipeline to share it with the team.
- ✓The auto-generated lineage graph is derived directly from ref() and source() calls in compiled SQL, which is exactly why it cannot drift out of sync the way a hand-drawn architecture diagram inevitably does.
- ✓meta: attaches structured, arbitrary key-value facts (ownership, PII classification, alerting channel); tags: attaches flat labels directly usable in dbt's --select syntax for grouping models into selective runs.
- ✓Documentation is cheapest to write the moment a model is created, while the author still has full context, and grows more expensive every month it is deferred — treating it as part of the same PR as the model change is the standard mitigation.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.