learn-analytics-engineering-with-phoebe / Builder session 9 of 10
Learn Analytics Engineering with Phoebe · Builder track · Session 9 of 10

Contracts, docs and deployment

The ship-it session. Session b7 gave you tests, which check the data. This one gives you contracts, which check the shape - and shape is what every BI tool, export and reverse-ETL sync silently depends on. Then exposures, so the consumers dbt cannot see appear in the lineage anyway; generated docs, which are a manifest this course's own site reads; and deployment - one repo, two targets, and a CI run that tests only what a pull request touched. Everything here exists because of the blind spot you found in b4.

🔴 Builder track Analytics engineers · analysts · DE Runs in your browser · real DuckDB 45 min
0-3 · Welcome 3-17 · Contracts versus tests 17-30 · Exposures and generated docs 30-45 · Deployment, CI, and Q&A
Part 0

What is left after the project works

The project builds, 100 tests pass, and the numbers reconcile. None of that protects the person on the other side of your marts. A dashboard that reads net_revenue from mart_merchant_daily has an implicit contract with you - a column name and a data type - and dbt has no idea it exists. You can rename that column, ship it in a green build, and find out on Monday.

Three mechanisms close that gap, and they are different in kind: a contract makes the shape enforceable, an exposure makes the consumer visible, and generated docs make both readable by someone who will never open your repo. Then deployment decides where all of it runs. This session is the whole set, in that order.

Live - presented in session Self-study - read after class ▶ Live SQL - real DuckDB, editable Sources covered
★ What you walk out with today A precise distinction between a test and a contract, a contract violation you have watched fail, exposures written for the four consumers this project actually has, an understanding of docs as data rather than as a website, and the CI selector state:modified+ with an honest account of what belongs in CI and what belongs to an orchestrator.
Part 1 · covers dbt documentation on model contracts and constraints

A test checks the data, a contract checks the shape 8 min live

Both are declared in YAML, both fail a build, and people conflate them constantly. The difference is when they run and what they look at. A test runs after the model is built and asks whether the rows are right. A contract is enforced as the model is built and asks whether the columns are what you promised. Shape is the thing other people depend on, and it is the thing tests cannot see.

TEST - checks the DATA runs AFTER the model is built a query that must return no rows unique, not_null, relationships, accepted_values, plus 5 singular failure: build fails, downstream SKIPs 100 of these in this project CONTRACT - checks the SHAPE enforced AS the model is built column names, data types, order plus not_null and check constraints contract: enforced: true, per model failure: nothing lands, no bad object what BI and reverse-ETL rely on EXPOSURE - names the CONSUMER declared, not enforced dashboard, export, ML, reverse-ETL depends_on: ref('mart_...') appears in lineage and in the docs selectable: +exposure:exec_funnel closes b4's blind spot Why shape is the failure mode that hurts other people A wrong number is discovered by whoever reads it. A renamed column, a widened type or a dropped field breaks a tool instead of a person: the dashboard errors, the sync half-writes, the export lands with a missing field and nobody notices for a week. Tests cannot see any of it, because the rows were perfectly fine.
🔍 Click to zoom - three mechanisms, three different jobs
TestContractExposure
Looks atrowscolumns and typeswho reads the model
Runsafter the model is builtwhile the model is builtnever - it is metadata
On failurebuild fails, downstream SKIPs, but the object existsbuild fails and nothing is createdcannot fail; it makes a dependency visible
Catchesduplicates, NULLs, orphan keys, a definition that moveda rename, a retype, a dropped column, a reordered selectthe consumer dbt has no edge to
Costbuild time, one query eachnear zero, plus rigidity you have to maintaina few lines of YAML per consumer
LiveWhat a contract looks like, and what happens when a model violates its own4 min▶

A contract is two things in the model's YAML: contract: enforced: true in the config, and a data_type on every column. Once both are present, dbt requires the model's compiled SELECT to produce exactly those columns with exactly those types. For mart_merchant_daily that promise is activity_date as DATE, merchant_id as VARCHAR, net_revenue as DOUBLE, aov_numerator as DOUBLE and aov_denominator as BIGINT.

Now suppose somebody tidies the model and renames net_revenue to revenue, and a round() turns aov_denominator into a DOUBLE on the way past. Three things happen, in this order:

  • The build fails at that model, before any object is created. There is no half-correct table for a dashboard to read.
  • The error names the columns. dbt reports the mismatch per column - what was promised, what the SELECT produced - so the diagnosis takes seconds rather than an afternoon.
  • Everything downstream SKIPs, exactly as it does for a failed test. The blast radius is contained by the same graph.

Constraints go one step further and push the promise into the warehouse itself: not_null, primary_key, unique and check can be declared alongside the types. How much of that is actually enforced depends on the warehouse - some platforms enforce all of it, some only not_null, and DuckDB sits in the middle. dbt is explicit about this in its docs and you should be explicit about it with your team: a constraint your platform does not enforce is documentation, and calling it enforcement is how people stop trusting the word.

Self-studyWhich models deserve a contract, and which do not3 min read▶

A contract is a promise, and a promise you make everywhere is a promise you will resent. The useful rule is that a contract belongs on a model with a consumer you cannot edit:

  • Yes on marts that a BI tool, an export or a reverse-ETL sync reads. These are the models where a rename is somebody else's outage. In this project that is the three mart_* models.
  • Yes on anything another team refs across a project boundary. If they cannot see your pull request, they need an interface, not goodwill.
  • No on staging models. They are cheap, disposable and consumed only by models in the same repo, where the DAG already tells you what breaks and CI already tests it.
  • No on intermediate models. They are ephemeral - there is no object, so there is nothing to have a shape.

The cost is real and it is not compute: it is that every column addition now touches two files, and a type change becomes a conversation. That is the correct amount of friction on a public interface and the wrong amount everywhere else. Model versions exist for the case where you need to change a contract without breaking today's consumers - you publish the new shape alongside the old one and migrate readers deliberately.

Self-studyThe honest limit of a contract2 min read▶

A contract guarantees that net_revenue exists and is a DOUBLE. It guarantees nothing about what net_revenue means. Somebody can change the model from settled revenue to gross revenue, keep the name and the type, and the contract will be perfectly satisfied while every dashboard on the other side becomes wrong by 8.1%.

That failure is the b7 lesson again, and it needs the b7 tool: a singular test comparing the mart's total against the fact it aggregates. Contracts and tests are complements, not alternatives - shape and meaning are two different promises, and a mature model layer makes both of them explicitly.

Part 2 · covers dbt documentation on exposures, docs, node selection and state comparison

Exposures, docs as data, and two targets 7 min live

A contract protects a consumer you cannot see. An exposure makes it visible. Generated docs make the whole thing readable. And a target decides where any of it lands - the same code, one flag, no diff between dev and prod.

1 · dev target dbt build your own schema nobody else reads it 2 · pull request the model as a diff a second person reads the definition change 3 · CI run --select state:modified+ build + test only the changed subgraph 4 · prod target --target prod same code, one flag main_marts, real readers What is CI's job, and what is not CI owns: build and test the changed subgraph on the pull request, and refuse to merge on any ERROR. It answers one question - is this change safe - and it must be fast enough that nobody learns to skip it. An orchestrator owns: when the production build runs, what runs before it, retries, backfills, alerting and SLAs. dbt Cloud can run both of these, and a plain CI runner plus cron, Airflow or Dagster does the same job. Neither arrangement is more real than the other, and this course teaches the selector rather than the product.
🔍 Click to zoom - one repo, two targets, and the line where dbt stops
LiveExposures: writing down the consumers dbt cannot see4 min▶

Session b4 ended on a blind spot: the graph knows every dependency you expressed with ref() and nothing about a dashboard, an export or a reverse-ETL sync. An exposure is a few lines of YAML that puts those back in the graph. Each one names the consumer, its type (dashboard, notebook, analysis, ml, application), an owner with an email, and its depends_on list of ref() calls.

Bazaar has four such consumers, and writing them down changes three things:

  • They appear in the lineage and in the generated docs, so a person reading mart_merchant_daily can see that a finance export and a CRM sync read it, with a name to contact.
  • They become selectable. dbt build --select +exposure:exec_funnel builds and tests everything that dashboard depends on and nothing else - which is the right command before a board meeting, and the right command when somebody asks "is the exec dashboard safe to look at".
  • An owner exists. The most useful field in an exposure is not technical. It is the person you have to tell.
The honest caveat An exposure is a declaration, not a discovery. Nothing scans your BI tool - somebody writes it down and somebody keeps it current, and a stale exposure is a lie with a lineage diagram attached. Which is exactly why the third box below tests exposures against the columns that actually exist.
Self-studyGenerated docs, and why docs-as-data is the real feature3 min read▶

dbt docs generate produces two JSON files in target/ and a static site that renders them:

  • manifest.json - every node, its description, its columns, its tests, its config, and the full parent and child maps. This is the project as data: 32 nodes and 47 edges for Bazaar, derived from the ref() calls and nothing else.
  • catalog.json - what the warehouse actually has: real column names, real data types, row counts, sizes. Queried from the warehouse after the build, not inferred from your SQL.

The site is the least interesting output. The point is that both files are machine-readable, so anything can read them: a catalog tool, a lineage tool, a Slack bot, a CI check that fails when a mart has no description. This course is itself an example. The lineage explorer in session b4 is not a picture anyone drew - it reads this project's manifest.json, and the raw-versus-compiled SQL on every node comes from the same place. That is the difference between documentation that drifts and documentation that is generated: the second kind cannot disagree with what was built, because it is what was built.

The one thing generation cannot do is write the description. A model with no description generates beautiful, empty documentation. Descriptions are the human half, and they are the half that decays.

Self-studyDev, prod, and why state comparison is the whole CI trick3 min read▶

A target is a named set of connection details in profiles.yml: which database, which schema, how many threads. This project has dev; a real deployment adds prod with a service account and the production schema. Because every model says ref() instead of a table name, the same code builds into your personal schema or the production one depending on a single flag. There is no branch, no environment variable inside the SQL, and no diff between what you tested and what ships.

That leaves the question of how CI stays fast. Rebuilding 23 models takes 1.22 seconds here and forty minutes on a real project, so testing everything on every pull request is how CI becomes something people learn to skip. state:modified+ is the answer:

  • CI downloads the manifest.json from the last successful production build - the "state".
  • --state path/to/prod/manifest --select state:modified+ compares the branch to it and selects the nodes whose definition changed, plus everything downstream of them.
  • dbt builds and tests exactly that subgraph. A one-line change to a mart tests that mart; a change to stg_bazaar__orders tests eight nodes, because the graph knows.

Two things make this bite in practice. First, you need somewhere to keep the production manifest - an artifact store, a bucket, whatever your CI already has. Second, a change to dbt_project.yml or a macro can modify far more than it looks like, so the selector is honest about being large sometimes. That is the correct behaviour and worth explaining to whoever asks why one CI run took twenty minutes.

Demo 1 of 2

Enforce a contract by hand ★ 7 min · everyone builds

dbt enforces a contract in Python, at build time, and there is no Python in your browser. What there is, is the mechanism: a declared shape, the shape the warehouse actually has, and a query that finds the difference. That query is what a contract does, written out so you can see it.

First the passing case. The contract for mart_merchant_daily against the model dbt actually built. Six columns, six matches, nothing to report - which is what a green build looks like from the inside.

Then the violation. The second box builds a proposed version of the same mart with a renamed column and a widened type, then runs the identical check. Two CONTRACT ERROR rows, and in a real build nothing would have landed at all.

Edit the contract, not the model. Change a promised type in the VALUES list and re-run the first box. Notice that a contract can be wrong too, and that being wrong is loud.

WITH contract(column_name, data_type) AS (
    VALUES ('activity_date',   'DATE'),
           ('merchant_id',     'VARCHAR'),
           ('merchant_name',   'VARCHAR'),
           ('net_revenue',     'DOUBLE'),
           ('aov_numerator',   'DOUBLE'),
           ('aov_denominator', 'BIGINT')
),
built AS (
    SELECT column_name, data_type
    FROM information_schema.columns
    WHERE table_schema = 'main_marts' AND table_name = 'mart_merchant_daily'
)
SELECT c.column_name,
       c.data_type AS promised_type,
       coalesce(b.data_type, 'not built') AS actual_type,
       CASE WHEN b.column_name IS NULL      THEN 'CONTRACT ERROR - column missing'
            WHEN b.data_type <> c.data_type THEN 'CONTRACT ERROR - type changed'
            ELSE 'ok' END AS build_verdict
FROM contract c
LEFT JOIN built b ON b.column_name = c.column_name
ORDER BY build_verdict, c.column_name;
-- Somebody 'tidies' the mart: renames a column and lets a cast widen a type.
CREATE OR REPLACE TABLE main.mart_merchant_daily__proposed AS
SELECT activity_date, merchant_id, merchant_name,
       net_revenue                     AS revenue,
       aov_numerator,
       cast(aov_denominator AS DOUBLE) AS aov_denominator
FROM main_marts.mart_merchant_daily;

WITH contract(column_name, data_type) AS (
    VALUES ('activity_date',   'DATE'),
           ('merchant_id',     'VARCHAR'),
           ('merchant_name',   'VARCHAR'),
           ('net_revenue',     'DOUBLE'),
           ('aov_numerator',   'DOUBLE'),
           ('aov_denominator', 'BIGINT')
),
built AS (
    SELECT column_name, data_type
    FROM information_schema.columns
    WHERE table_schema = 'main' AND table_name = 'mart_merchant_daily__proposed'
)
SELECT c.column_name,
       c.data_type AS promised_type,
       coalesce(b.data_type, 'not built') AS actual_type,
       CASE WHEN b.column_name IS NULL      THEN 'CONTRACT ERROR - column missing'
            WHEN b.data_type <> c.data_type THEN 'CONTRACT ERROR - type changed'
            ELSE 'ok' END AS build_verdict
FROM contract c
LEFT JOIN built b ON b.column_name = c.column_name
ORDER BY build_verdict, c.column_name;
Real world

The rename in that second box is a perfectly reasonable pull request. revenue is a better column name than net_revenue if you already know the mart is settled-only, the cast was added to fix a rounding complaint, all 100 tests still pass, and the reviewer approves it in ninety seconds because the diff is two lines. Without a contract this ships, and the failure surfaces as a broken tile in a finance dashboard and a reverse-ETL sync that half-wrote a CRM field. With a contract the build fails on the author's own machine, before the pull request exists. That is the entire value proposition, and it is worth the two files you now have to keep in step.

Demo 2 of 2

Exposures, the catalog, and the CI selector ★ 8 min · build your own

Three things dbt gives you as data rather than as a screen: the consumers you declared, the catalog the warehouse reports, and the subgraph a change touches. All three are queryable, which is the point.

LiveQ1 · Test your exposures against the columns that exist3 min▶

These are Bazaar's four consumers, written as an exposure list with the columns each one reads. The join against information_schema is a check nobody usually writes and everybody needs: does every column an exposure claims actually exist in the model it depends on?

WITH exposures(exposure_name, exposure_type, depends_on, column_used) AS (
    VALUES ('merchant_scorecard', 'dashboard',   'mart_merchant_daily',      'net_revenue'),
           ('merchant_scorecard', 'dashboard',   'mart_merchant_daily',      'merchant_tier'),
           ('exec_funnel',        'dashboard',   'mart_funnel_daily',        'carts_converted'),
           ('exec_funnel',        'dashboard',   'mart_funnel_daily',        'net_revenue'),
           ('ops_deploy_window',  'dashboard',   'mart_hourly_transactions', 'attempts'),
           ('crm_sync',           'reverse_etl', 'mart_merchant_daily',      'merchant_id'),
           ('finance_export',     'export',      'mart_merchant_daily',      'commission_amount')
)
SELECT e.exposure_name, e.exposure_type, e.depends_on, e.column_used,
       CASE WHEN c.column_name IS NULL
            THEN 'BREAKS - column not in the model' ELSE 'ok' END AS status
FROM exposures e
LEFT JOIN information_schema.columns c
       ON c.table_schema = 'main_marts'
      AND c.table_name   = e.depends_on
      AND c.column_name  = e.column_used
ORDER BY status, e.exposure_name;

In a real project this check belongs in CI, and it is the difference between an exposure that documents and an exposure that protects. Note also what the result gives you for free: dbt build --select +exposure:finance_export is now the command that rebuilds and tests exactly what that export depends on.

LiveQ2 · This is catalog.json, queried live2 min▶

dbt docs generate builds catalog.json by asking the warehouse what it actually has. Here is the same question, asked directly. Twelve mart objects, every column, every type - and the fact that this is a query rather than a document is the whole idea behind docs-as-data.

SELECT table_name,
       count(*) AS columns,
       sum(CASE WHEN data_type LIKE '%INT%' OR data_type = 'DOUBLE'
                THEN 1 ELSE 0 END) AS numeric_columns,
       sum(CASE WHEN data_type = 'VARCHAR' THEN 1 ELSE 0 END) AS text_columns,
       sum(CASE WHEN data_type IN ('DATE', 'TIMESTAMP') THEN 1 ELSE 0 END) AS date_columns
FROM information_schema.columns
WHERE table_schema = 'main_marts'
GROUP BY table_name
ORDER BY table_name;

A CI check built on this shape is three lines long and catches real problems: a mart with a column count that changed unexpectedly, a fact table that acquired a text column, a dimension that lost its date. None of that needs a data-catalog product.

LiveQ3 · What state:modified+ actually selects3 min▶

The selector walks edges. Here are real edges from this project's manifest.json, and a recursive CTE doing exactly the walk dbt does when you change one staging model. The answer - eight nodes including the modified one - is what CI would build and test on that pull request, and nothing else.

WITH RECURSIVE edges(parent, child) AS (
    VALUES ('stg_bazaar__orders', 'fct_carts'),
           ('stg_bazaar__orders', 'fct_transactions'),
           ('stg_bazaar__orders', 'int_order_lines_enriched'),
           ('stg_bazaar__carts',  'fct_carts'),
           ('int_cart_contents',  'fct_carts'),
           ('int_order_lines_enriched', 'fct_order_items'),
           ('fct_carts',        'mart_funnel_daily'),
           ('fct_order_items',  'mart_funnel_daily'),
           ('fct_order_items',  'mart_merchant_daily'),
           ('fct_transactions', 'mart_funnel_daily'),
           ('fct_transactions', 'mart_hourly_transactions')
),
downstream(node, hops) AS (
    SELECT 'stg_bazaar__orders', 0
    UNION
    SELECT e.child, d.hops + 1
    FROM edges e JOIN downstream d ON e.parent = d.node
)
SELECT node, min(hops) AS hops_away
FROM downstream
GROUP BY node
ORDER BY hops_away, node;

Change the starting node to fct_order_items and the selection drops to three. That difference is why state:modified+ keeps CI in minutes on a project where a full build takes an hour, and why the graph you got for free in b4 turns out to be the thing that makes deployment affordable.

Self-studyQ4 · What a target looks like from inside the warehouse2 min▶

Twenty objects in two schemas, and the schema names are the only trace a target leaves. Point the same code at prod and this result is identical except for the prefix - which is the strongest argument for ref() there is.

SELECT table_schema, table_type, count(*) AS objects
FROM information_schema.tables
WHERE table_schema IN ('main_staging', 'main_marts')
GROUP BY 1, 2
ORDER BY 1, 2;

Twenty objects for twenty-three models, because the three intermediate models are ephemeral. Worth remembering when somebody asks why the docs site lists more models than the warehouse has tables.

Homework

Try it yourself - this week ◐ 30-40 min total

Source material

Sources covered

This page covers:

✓dbt documentation - model contracts, enforced: true, column data types, constraints, model versionsPart 1 + Demo 1 · a contract satisfied and a contract violated, with the platform caveat stated
✓dbt documentation - exposures, generated docs, manifest.json and catalog.jsonPart 2 + Demo 2 · four real consumers, and this course's own site as the docs-as-data example
✓dbt documentation - node selection and state comparison (state:modified+, +exposure:, targets)Part 2 self-study + Demo 2 Q3 · the selector walked over this project's real edges
◐dbt Cloud as one way to run CI and production buildsNamed as one option beside a plain CI runner · no UI walkthrough, because that changes quarterly
◐Kimball & Ross - the marts these contracts are placed onRecap only for the models being implemented · the model is learn-data-modeling
◐Scheduling, retries, backfills and alertingOut of scope by design - that is an orchestrator's job and it is learn-dataops; loading the raw tables is learn-data-engineering
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What is the difference between a test and a contract?

Shape is what BI tools, exports and reverse-ETL syncs depend on, and tests structurally cannot see a rename because the rows were fine. They are complements: a contract cannot notice that net_revenue quietly became gross revenue, which is what a singular test is for.

2 · What does an exposure do?

Nothing scans anything: somebody writes it down and somebody keeps it current, which is why a stale exposure is a lie with a lineage diagram attached. The most useful field in one is the owner, because that is the person you have to tell.

3 · A pull request changes one staging model. What should CI build?

Building everything is how CI becomes something people learn to skip; building only the changed model misses the seven nodes downstream of it. The graph already knows the answer, and CI's job is exactly one question: is this change safe. When production runs is an orchestrator's job.

Builder session 9 cheat sheet · pin this

Test vs contractA test checks DATA after the build. A contract checks SHAPE during it, and nothing lands on failure.
Contract syntaxcontract: enforced: true plus a data_type per column. Constraints add not_null, unique, check - enforcement varies by warehouse.
Where contracts belongMarts with consumers you cannot edit. Not on staging, never on ephemeral models.
Contracts do not check meaningSettled revenue can become gross revenue with the name and type intact. That needs a singular test.
ExposuresDeclare the dashboards, exports and syncs dbt cannot see. Name an owner. Select with +exposure:name.
Docs are datadbt docs generate writes manifest.json (32 nodes, 47 edges) and catalog.json. This course's lineage explorer reads the manifest.
One repo, two targetsref() resolves per target, so dev and prod differ by a flag rather than a diff.
CI vs orchestratorCI: build + test state:modified+ on the PR. Orchestrator: when prod runs, retries, alerting - that is learn-dataops. Next: b10, the capstone.