Week 10: dbt Transformations

Introduction to dbt Core

dbt Setup for Azure PostgreSQL

SQL and Jinja Templating

Materializations & Layers

dbt Tests

Docs & Extras

Practice

Assignment: Borough Stats

Gotchas & Pitfalls

Slides (PDF)

Career relevance: Week 10 in the NL data job market

Glossary

History of Analytics Engineering

Going Further: Optional Deep Dives

Docs & Extras

In dbt Tests you attached tests to your models so that the producer of the data (you) can trust what leaves the pipeline. This chapter is about the other side of that contract: the consumers: analysts, dashboard builders, the next person on the team: who need to find your mart, understand what it means, and decide whether they can trust it.

Tests answer "are the numbers right?" Documentation answers "what does this column mean?" and "where did it come from?" Without either, a dashboard that says "revenue" is a guess. With both, it's a claim that can be audited.

This chapter reuses the _stg_trips.yml, _stg_zones.yml, and _fct_trips.yml files you wrote in dbt Tests: you already have the skeleton; you are adding descriptions to it. Then you generate and host a documentation site with lineage. After that, the chapter covers the package ecosystem (you already used dbt_utils in the previous chapter; this adds context).

By the end of this chapter, you should be able to:

<aside> 📦 Reference repo: The end-state of this chapter (schema YAMLs with descriptions and a generated docs site) lives at tag ch6-docs-extras-solution in the reference repo. The optional snapshots deep-dive on the Going Further page has its own going-further branch, so this chapter's end-state stays snapshot-free. As with the other chapters: type it yourself first, consult the tag when something breaks.

</aside>

Documentation: the YAML you already have, with more words

In the four built-in generic tests every column in your _stg_trips.yml already has a description: field: you wrote them when you added tests. What you have not done yet is generate the HTML site that turns those descriptions into something a non-dbt user can browse. The site is free: dbt builds it from the same YAML files you already maintain.

Column and model descriptions

Two lessons from the previous chapter already set up the description workflow:

Open _fct_trips.yml from the dbt Tests practice. Right now it probably has a one-line stub. Flesh it out:

# models/marts/_fct_trips.yml
version: 2

models:
  - name: fct_trips
    description: |
      One row per completed NYC green taxi trip in January 2024, with
      pickup/dropoff zone attributes folded in (OBT-style mart). Queried
      directly by dashboards and ad-hoc analysis.

      **Grain:** one row per trip.
      **Source:** `nyc_taxi.raw_trips` joined to `nyc_taxi.raw_zones` on
      `pickup_location_id` and `dropoff_location_id`.
      **Not included:** trips where `pickup_location_id` is NULL (dropped
      in `stg_trips`); duplicate rows from the TLC source are kept as-is
      and surfaced by `dbt_utils.unique_combination_of_columns`.
    columns:
      - name: pickup_datetime
        description: Wall-clock time the trip began (America/New_York, no timezone attached).
        tests: [not_null]
      - name: fare_amount
        description: Metered fare in USD, not including tip, tolls, or surcharges.
      - name: tip_pct
        description: |
          `tip_amount / fare_amount`, rounded to 4 decimals. NULL when
          `fare_amount` is 0 (voided trips, no-charge rides).
      - name: pickup_borough
        description: |
          NYC borough of the pickup zone, joined from `stg_zones.borough`.
          Values: Manhattan, Brooklyn, Queens, Bronx, Staten Island, EWR,
          Unknown, NaN, or NULL when `pickup_location_id` did not resolve.

The | (YAML literal block) lets a description span multiple lines and keeps newlines as written: useful for anything longer than a sentence.

<aside> 💡 What's mandatory vs. convention. The only parts dbt actually parses are the YAML keys: version, models, name, description, columns, tests. Everything inside a description: value is free-form Markdown: dbt dumps it straight into the docs site without interpreting it. The **Grain:**, **Source:**, **Not included:** labels above are a house convention for making mart docs scannable, not dbt syntax. Use them, replace them with your own headings, or skip them entirely; dbt does not care. What matters is that every mart column has some description that helps the next reader.

</aside>

Rule of thumb: document every column in every mart. Staging and intermediate models can be sparser: they are an implementation detail: but a mart is a contract, and each column is a public API.

<aside> 💡 Using AI to help: Writing good descriptions for 30 columns by hand is tedious. A practical use of an LLM here is: paste your model's select list and the underlying source's column semantics, ask the model to draft one-sentence descriptions, then edit for accuracy. ⚠️ Ensure no PII or sensitive company data is included: for this week's public NYC TLC dataset that is not a concern, but on a real project always scrub customer identifiers, business metrics, or internal URLs before sending columns to a model.

</aside>

Doc blocks: longer prose, reused in multiple places

When a description grows past a couple of sentences, or when the same text needs to live next to five columns, move it into a doc block: a named chunk of Markdown in a .md file under models/.

<!-- models/marts/fct_trips_docs.md -->
{% docs trip_grain %}

One row per completed taxi trip. "Completed" means the TLC submitted the
trip record to the public dataset; cancellations and trips in progress
are not included. Duplicates exist in the source data (roughly 4 rows
in January 2024) and are kept as-is; see `dbt_utils.unique_combination_of_columns`
test results.

{% enddocs %}

Then reference it from YAML with the {{ doc(...) }} function:

- name: fct_trips
  description: '{{ doc("trip_grain") }}'

Rule of thumb: reach for doc blocks when the description runs 3+ lines, contains lists or formatting, or would otherwise be duplicated across models.

dbt docs generate and dbt docs serve

Two commands turn the YAML into a browsable site:

dbt docs generate

This reads every schema YAML, introspects your warehouse for column types and row counts (via a SELECT ... FROM information_schema query per model), and writes three files into target/:

<aside> 🤓 Curious Geek: why information_schema works on every warehouse

The reason dbt docs generate can introspect Postgres, BigQuery, Snowflake, and Redshift at all is that information_schema is part of the SQL standard (SQL-92). Every compliant database exposes comparable read-only views (information_schema.columns, information_schema.tables, ...) describing its own structure. Each dbt adapter still ships its own introspection macro (Postgres also reads pg_catalog, BigQuery scopes it per dataset), but they all lean on that shared standard, which is why one dbt docs generate command pulls column types and row counts from whatever warehouse you point it at.

</aside>

Then:

dbt docs serve --port 8001

Starts a local web server at http://localhost:8001. The page you land on lists every model, source, and test in the project. Click a model to see its description, columns, tests, and compiled SQL. In the bottom-right corner there is a blue "view lineage graph" button: click it.

dbt docs: fct_trips model page showing description, details, and column table

dbt docs: fct_trips model page showing description, details, and column table

The fct_trips page above is what your _fct_trips.yml description and column descriptions render into: Markdown in the description, the column types pulled from Postgres, the attached tests surfaced inline. Every bit of prose a stakeholder reads here came from a YAML field you maintain.

<aside> 💡 If port 8001 is already busy (a previous dbt docs serve you forgot to stop), pick any other port (--port 8002). The default port 8080 collides with many other tools, which is why the dbt docs recommend choosing one explicitly.

</aside>

The lineage graph

The lineage graph shows every model as a node and every ref() / source() call as an edge. For the nyc_taxi project you will see four nodes and four edges: the two raw sources feeding two staging models, both feeding the mart:

dbt docs lineage graph showing raw_trips and raw_zones feeding stg_trips and stg_zones, which both feed fct_trips

dbt docs lineage graph showing raw_trips and raw_zones feeding stg_trips and stg_zones, which both feed fct_trips

Source nodes are green, staging models are cyan, marts are purple. Click fct_trips in the graph and its upstream dependencies stay visible while everything else fades; do the same on a stg_* model and you see which marts depend on it. On a real project with 50+ models, this is the single most useful navigation tool dbt ships: before you change a column in stg_trips, the lineage graph tells you which marts will need review.

<aside> 💡 The docs site as a team communication tool. In a real team the docs site is hosted somewhere anyone can reach it (often as static HTML in Azure Blob Storage / Azure Static Web Apps, Cloudflare Pages, or GitHub Pages, regenerated on every merge to main). When a stakeholder asks "where does revenue come from?", you send them a link to the mart's page in the docs site instead of explaining it in Slack. The docs site is how dbt turns an internal engineering tool into a cross-functional one. Setting up hosted docs is outside this course's scope; see the dbt docs' Hosting and serving guide when you get to it on your own project.

</aside>

The package ecosystem

You already used a package in Putting it all together: dbt_utils.unique_combination_of_columns shipped the multi-column uniqueness test your project would have had to implement by hand. Packages are how the dbt community collects reusable macros, tests, and materializations without every project writing the same code.

The packages.yml file you wrote in the previous chapter already has the pattern:

# packages.yml
packages:
  - package: dbt-labs/dbt_utils
    version: [">=1.1.0", "<2.0.0"]

dbt deps reads the file, resolves versions, and installs each package into dbt_packages/. The macros and tests inside become available under the package's namespace: dbt_utils.generate_surrogate_key(...), dbt_utils.unique_combination_of_columns, and so on.

Beyond dbt_utils, the dbt packages hub is the discovery surface for anything else. When you meet a dbt pattern that feels like it should already exist (pivot tables, JSON parsing, better assertion libraries, calendar-table macros), check the hub before writing it yourself. The Going Further page lists the two community packages most projects reach for after dbt_utils.

<aside> ⚠️ Every package you add is code you did not write running against your warehouse. Pin versions (as above), read the package's README for what permissions it needs, and keep an eye on release notes for breaking changes. dbt deps does not audit what it installs.

</aside>

⌨️ Hands on

Four steps that turn the schema YAML you already have into a browsable docs site.

1. Flesh out descriptions in _fct_trips.yml

Open models/marts/_fct_trips.yml. The Chapter 5 practice left it as a two-line stub with just a not_null test on pickup_datetime. Replace it with the fuller example from the Column and model descriptions section above: a multi-paragraph model description: using the | literal block, and a description: on every column in the fct_trips select (pickup_datetime, dropoff_datetime, fare_amount, tip_pct, fare_per_mile, payment_type_label, pickup_borough, pickup_zone, dropoff_borough, dropoff_zone).

Keep each column description to one or two sentences. The goal is to give an analyst enough to decide whether the column is what they want, not to re-derive the SQL.

2. Generate the catalog

dbt docs generate
Found 3 models, 10 data tests, 2 sources, 581 macros, 1 unit test
Concurrency: 4 threads (target='dev')
Building catalog
Catalog written to target/catalog.json

The Building catalog step is dbt introspecting the warehouse: one information_schema query per model: to capture the column types and row counts the docs site displays. If you see a warning about "relations not found in the catalog," run dbt run --select +fct_trips first: dbt cannot introspect columns in tables that do not exist yet.

3. Serve the site and explore

dbt docs serve --port 8001

Open http://localhost:8001. In the left sidebar, expand nyc_taxi > models > marts > fct_trips. The center panel shows the description you just wrote, the column list with their types inferred from Postgres, the tests attached to each column, and the compiled SQL.

Click the blue view lineage graph button in the bottom-right. Press ctrl/cmd + C to stop the server when you are done.

4. Read the generated catalog

cat target/catalog.json | python3 -m json.tool | head -40

Notice that dbt has recorded every column's Postgres type and every model's row count. This is the introspection step that makes the docs site richer than just your YAML: it pulls live metadata from the warehouse.

<aside> ⌨️ Stretch goal: add a doc block at models/marts/fct_trips_docs.md with a longer explanation of the mart's grain (using the {% docs trip_grain %} / {% enddocs %} syntax from the Doc blocks section) and reference it from _fct_trips.yml with {{ doc("trip_grain") }}. Regenerate the docs site and confirm the block renders as formatted Markdown in the fct_trips page.

</aside>

Once the docs site builds cleanly and the lineage graph renders, you have completed the chapter's core workflow. The practice chapter is where you repeat it until it feels routine.

<aside> 📝 Practice: The exercises in this week's practice extend the documentation work from this chapter onto a slightly different slice of the data. Finish the hands-on above before attempting them.

</aside>

Knowledge Check

<aside> 🚀 Try it in the widget: Interactive Quiz: Docs & Extras

</aside>

https://lasse.be/simple-hyf-teach-widget/mcq.html?bank=week_10_ch6_docs_extras_quiz&embed=1

Ready for the next chapter when

Chapter 7 is the week's practice exercise: you will combine everything from Chapters 2-6 on a slightly larger slice of the same dataset. Before you start, confirm the basics are in hand:

Extra reading

<aside> 🎬 Struggling with this concept? Watch this beginner-friendly video:

</aside>

https://www.youtube.com/watch?v=UqoWyMjcqrA

If the chapter leaves you curious about adjacent tooling, the Going Further page is where the optional material lives.

<aside> 📚 For deeper dives: the optional snapshots deep-dive (SCD Type 2 row history), the dbt Power User VS Code extension, and incremental-model references: see the Going Further page.

</aside>

And when something in your own project does not match what the chapter describes, the reference repo has the end-state to diff against:

<aside> 📦 Reference repo: Type this chapter yourself, then practice from a clean base with git switch ch6-docs-and-extras in your clone (it starts from Chapter 5 finished). Stuck? Diff your project against the ch6-docs-extras-solution tag.

</aside>


Next up: Practice, where you apply the full Week 10 workflow (sources, staging models, macros, a mart, tests, and docs) in a hands-on exercise until the pipeline feels routine.