Lab 06 — Data Quality & Tests
A pipeline that runs is not the same as a pipeline you can trust. In this lab you add the trust layer to mini-GridDP: dbt schema tests on the gold star schema, a freshness check that catches stale data, and a custom assertion that enforces a business rule. Then you'll deliberately break a row, watch a test fail with a clear message, fix it, and watch it go green — and finally wire dbt test into the Dagster pipeline so quality runs every time the data does.
You need two things from earlier labs running: the dbt project from Lab 03 (the dbt/ folder with dim_customer, dim_gpu, fct_rentals, and fct_telemetry_hourly built into DuckDB), and the Dagster pipeline from Lab 05. From your repo root, run the pipeline once so bronze and gold are both fresh, then move into the dbt project, where most of this lab happens:
cd mini-griddp
uv run dagster job execute -j full_pipeline -m dagster_app.definitions # ingest + model
cd dbt
uv run dbt ls --resource-type model # dim_customer, fct_rentals, fct_telemetry_hourly among themIf the run ends in RUN_SUCCESS and the gold models are listed, you're ready. Everything in this lab is added inside the dbt/ project and the dagster_app/ folder.
The goal — a trust layer
So far mini-GridDP moves data: Postgres → bronze → silver → gold, on a schedule. But nothing yet checks that the data is correct. A duplicated rental, a customer that points at a tier that doesn't exist, a feed that silently stopped loading last Tuesday — all of these pass through happily and poison the dashboard. The fix is a layer of automated assertions that run with the pipeline and fail loudly when reality violates an expectation.
Tests sit after transformation and before serving — a gate the data must pass to reach the dashboard:
By the end of this lab the gate has three kinds of checks:
| Check | Catches | How |
|---|---|---|
| Schema tests | Nulls in keys, duplicate keys, broken joins, invalid categories | dbt generic tests in the models' YAML (_marts.yml) |
| Freshness | A feed that silently stopped — yesterday's data masquerading as today's | dbt source freshness |
| Custom assertions | Business rules: negative revenue, zero-hour rentals, row counts that don't reconcile | dbt singular tests (SQL) |
Every dbt test compiles to a SELECT. If that query returns any rows, those rows are the failures and the test fails. Hold onto this idea — it makes both the built-in tests and the custom ones you'll write feel like the same simple thing.
Schema tests on the gold models
dbt ships four generic tests you'll reach for constantly: not_null, unique, relationships (a foreign-key check), and accepted_values. You declare them in a YAML file beside your models — no SQL required. Lab 03 left a small _marts.yml with four tests; grow it into the full contract for the star schema. (A model may be described in only one YAML file, so extend this one rather than adding a schema.yml next to it.)
version: 2
models:
- name: fct_rentals
description: "One row per GPU rental, priced as-of the start time. The central fact of the star schema."
columns:
- name: rental_id
description: "Surrogate key — unique per rental."
tests:
- not_null
- unique
- name: customer_id
description: "FK to dim_customer."
tests:
- not_null
- relationships:
arguments:
to: ref('dim_customer')
field: customer_id
- name: gpu_id
description: "FK to dim_gpu."
tests:
- not_null
- relationships:
arguments:
to: ref('dim_gpu')
field: gpu_id
- name: hours
tests:
- not_null
- name: revenue
tests:
- not_null
- name: dim_customer
description: "Customer dimension (Type 1 — one row per customer, current values)."
columns:
- name: customer_id
tests:
- not_null
- unique
- name: tier
description: "Pricing tier the customer is on."
tests:
- accepted_values:
arguments:
values: ['free', 'pro', 'enterprise']Run just the tests (no rebuild) and read the summary:
uv run dbt test --select fct_rentals dim_customerA line per test and a green summary: PASS=11 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=11, eight tests on the fact and three on the dimension. dbt names each test automatically — e.g. unique_fct_rentals_rental_id and relationships_fct_rentals_customer_id__customer_id__ref_dim_customer_. Every one passing means: no duplicate or null rental IDs, every rental points at a real customer and GPU, and every customer sits on a known tier. That is a real contract, enforced.
unique + not_null on every primary key catches the single most common warehouse bug — a fan-out join that silently doubles your rows and inflates revenue. relationships catches the second — an orphaned fact whose dimension was never loaded. Put them on every key in every gold table and you've eliminated a whole class of "the numbers look wrong" incidents.
Freshness — is the data even current?
Schema tests check that the data is shaped right. They say nothing about whether it's recent. If the Postgres extract silently failed three days ago, every schema test still passes — the old rows are perfectly valid, just stale. Freshness is the check that catches a feed that quietly died.
dbt has freshness built in for sources. Every bronze row carries the loaded_at stamp that the Lab 02 extractor and the Lab 04 consumer write. In your sources file, point dbt at that column and set thresholds:
version: 2
sources:
- name: bronze
schema: bronze
description: Raw tables landed by the Lab 02 ingest and the Lab 04 stream consumer.
loaded_at_field: loaded_at # the ingest timestamp we stamp on each row
freshness:
warn_after: {count: 6, period: hour} # noisy but ok
error_after: {count: 24, period: hour} # a full day stale = broken
tables:
- name: customers
- name: gpus
- name: gpu_prices
- name: rentals
- name: telemetry
freshness: # telemetry is a firehose — tighter bound
warn_after: {count: 30, period: minute}
error_after: {count: 2, period: hour}Run the freshness check on its own:
uv run dbt source freshnessOne line per source table with a verdict — PASS, WARN, or ERROR STALE — such as PASS freshness of bronze.rentals. The four Postgres tables pass: the pipeline you ran in the setup stamped them minutes ago. telemetry passes only if the stream consumer landed something in the last 30 minutes; if your Lab 04 producer has been off for longer, it warns or errors, which is exactly the alarm this check exists to raise. Run the producer for a minute, drain, and it goes green again.
Freshness reads the max of a timestamp column and compares it to "now" — it doesn't scan every row, so it's cheap to run often. Reach for it whenever "did this feed update?" matters more than "is each row correct?". For mini-GridDP, run it at the top of every pipeline tick so a stale feed stops the run before it builds a misleading dashboard.
A custom assertion — enforce a business rule
The generic tests don't know your business. Revenue is never negative. A rental lasts more than zero hours. Every rental in bronze made it into the fact table. Those are singular tests — a single SQL file in tests/ that selects the rows that violate the rule. Remember the golden rule: a test passes when its query returns zero rows.
Write one that asserts rental hours and revenue are always positive:
-- Fail if any rental has non-positive hours or negative revenue.
-- A passing test returns ZERO rows.
select
rental_id,
hours,
revenue
from {{ ref('fct_rentals') }}
where hours <= 0
or revenue < 0And a reconciliation test — every rental that landed in bronze should appear in the gold fact, so the counts must match:
-- Fail if the gold fact row count drifts from the bronze source.
-- Returns a single row only when the counts disagree.
with bronze as (
select count(*) as n from {{ source('bronze', 'rentals') }}
),
gold as (
select count(*) as n from {{ ref('fct_rentals') }}
)
select bronze.n as bronze_rows, gold.n as gold_rows
from bronze, gold
where bronze.n != gold.nRun your custom tests:
uv run dbt test --select test_type:singularBoth pass: PASS assert_rentals_positive and PASS assert_rental_count_reconciles. Each one ran its SELECT, got zero rows back, and reported green. You've just encoded two business invariants as code that runs forever — no more "I assume revenue is positive," only "the test proves it."
Break it, then fix it
A test you've never seen fail is a test you don't trust. Let's prove the trust layer works by injecting a bad row — a rental with negative hours — straight into bronze, rebuilding, and running the suite.
# open a DuckDB shell on the warehouse (you are in dbt/, so one level up)
duckdb ../warehouse.duckdb
-- inside duckdb: a rental that lasted -5 hours. Nonsense, but realistic
-- (a clock skew or a bad upstream record could produce exactly this).
-- rental_id 0 can never collide with a SERIAL id and leaves the Lab 02 watermark alone.
INSERT INTO bronze.rentals (rental_id, customer_id, gpu_id, hours, started_at)
VALUES (0, 1, 7, -5, now());
.quitNow rebuild gold so the bad row flows through. dbt build runs the fact's tests right after the model, so the alarm sounds here already; dbt test then runs the whole suite:
uv run dbt build --select fct_rentals
uv run dbt testA red failure with a message that points right at the problem:
3 of 11 FAIL 1 assert_rentals_positive .................. [FAIL 1 in 0.05s]
[ERROR]: in test assert_rentals_positive (tests/assert_rentals_positive.sql)
Got 1 result, configured to fail if != 0
compiled code at target/compiled/mini_griddp/tests/assert_rentals_positive.sql
Done. PASS=10 WARN=0 ERROR=1 SKIP=0 NO-OP=0 REUSED=0 TOTAL=11One ERROR, ten PASS (the model plus nine tests); dbt test then reports PASS=12 ERROR=1 TOTAL=13 across all thirteen tests. dbt even tells you how to inspect the offending rows — run the compiled SQL and you'll see rental_id = 0 with hours = -5 and a negative revenue. The gate caught it before it could reach the dashboard.
Now fix it — remove the bad row, rebuild, and re-test:
duckdb ../warehouse.duckdb "DELETE FROM bronze.rentals WHERE rental_id = 0;"
uv run dbt build --select fct_rentals
uv run dbt testBack to green: Done. PASS=13 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=13. That fail → fix → pass cycle is the whole point of a trust layer. The test didn't just sit there being green; it actively caught a real defect, named it, and went green again only once the data was correct.
Wire tests into the Dagster pipeline
Tests that only run when you remember to type dbt test aren't a trust layer — they're a good intention. The point is that quality runs automatically with the pipeline, so bad data is caught the moment it's produced, on every scheduled tick. In Dagster, the clean way to express "this data must pass these checks" is an asset check.
The schema and singular tests are already wired in. Look back at the Lab 05 dbt_build asset: it runs dbt build, and build is run + test. A failing test makes dbt exit non-zero, _run raises, the asset goes red and fct_telemetry_hourly never starts. You proved that by hand a moment ago; the scheduler gets the same gate for free. Freshness is the one check that does not run inside dbt build, so add it as a Dagster asset check on the bronze asset it describes:
from dagster import asset_check, AssetCheckResult # add to the existing import
# A freshness gate, expressed as a Dagster asset check on bronze_business.
# It fails the check if `dbt source freshness` errors on bronze.rentals,
# and blocking=True stops the run there instead of building gold on stale data.
@asset_check(asset=bronze_business, blocking=True)
def rentals_are_fresh(context) -> AssetCheckResult:
result = subprocess.run(
["dbt", "source", "freshness", "--profiles-dir", ".",
"--select", "source:bronze.rentals"],
cwd=DBT_DIR, capture_output=True, text=True,
)
context.log.info(result.stdout.strip())
return AssetCheckResult(
passed=result.returncode == 0, # dbt exits 1 on ERROR STALE
metadata={"detail": MetadataValue.text("dbt source freshness on bronze.rentals")},
)Reload the Dagster code location and materialize the pipeline (from the repo root):
cd ..
uv run dagster dev # then "Materialize all" in the UI, or headless:
uv run dagster job execute -j full_pipeline -m dagster_app.definitionsA new step, bronze_business_rentals_are_fresh, right after bronze_business, and in the UI a Checks tab on that asset with rentals_are_fresh reporting Passed. Re-inject the rental_id = 0 row, materialize again, and the run goes red at dbt_build: Dagster captured dbt's FAIL 1 assert_rentals_positive in the step's logs, and fct_telemetry_hourly was never attempted. Quality now runs every time the pipeline does — no remembering required.
A dbt test failure blocks downstream because the whole dbt build step fails. An asset check is different: by default it only reports, and downstream assets still run. blocking=True turns it into a gate, so a stale bronze.rentals stops the run before dbt rebuilds gold and before the semantic layer in Lab 07 reads it. That's the difference between a test that reports a problem and a gate that stops bad data.
Commit Lab 06
You've added a whole layer to the platform. Commit it so the trust layer is part of the story your git history tells.
git add dbt/models/marts/_marts.yml \
dbt/models/staging/_sources.yml \
dbt/tests/ \
dagster_app/assets.py
git status # confirm only the trust-layer files are staged
git commit -m "Lab 06: data quality — schema tests, freshness, custom assertions, Dagster checks"A commit summarizing the new _marts.yml contracts, the freshness config, two singular tests, and the wired-in Dagster asset check. Run git log --oneline and you'll see Lab 06 sitting right after Lab 05 — the pipeline now ships with its own quality gate.
✓ Check yourself
Answer from memory before opening each one.
-
A join in
fct_rentalsfans out and every rental appears twice, doubling revenue on the dashboard. Every column is still populated and every foreign key still resolves. Which test goes red, and what does its query return?Show answer
uniqueonrental_id. The test compiles to aSELECTfor key values that occur more than once, and any rows returned are the failures.not_nullandrelationshipsstay green, because each duplicated row is perfectly well-formed. See Schema tests and the zero-rows rule. -
The Postgres extract died three days ago. Since then every schema test and both singular tests have passed on every run. Why — and which check would have caught it on day one?
Show answer
Those tests judge the shape of the rows that are there, and three-day-old rows are still unique, non-null and positive. Staleness is about rows that never arrived:
dbt source freshnesscomparesmax(loaded_at)with now and errors once the gap passes the threshold. See Freshness. -
Marketing launches a
startuptier and new customers begin arriving with it. What happens at the nextdbt test, and what is the right fix?Show answer
accepted_valuesondim_customer.tierfails and returns the value it does not recognise. The data is not wrong — the contract is out of date. Confirm the tier is real, then add it to thevalueslist in_marts.yml; filtering the rows out or disabling the test throws away the check that will catch the next value that is a typo. See Schema tests on the gold models. -
An at-least-once ingest lands one rental twice in bronze, and staging deduplicates it as designed. What does
assert_rental_count_reconcilesreport, and is it right to?Show answer
It fails: bronze has one more row than gold, so the query returns a row. But gold is correct — the test compares
count(*)on both sides, so it reads legitimate deduplication (or any row staging deliberately filters out) as loss. Reconcile on what ought to match, such ascount(distinct rental_id)under the same filters; a test that fails on correct data teaches people to ignore it. See A custom assertion. -
The Lab 05
dbt_buildasset callsdbt buildrather thandbt run. What would the pipeline do with a negative-hours rental underrun, and what changes underbuild?Show answer
runonly builds models — no test executes, so the bad row flows intofct_rentalsand on toward the dashboard while the run stays green.buildis run + test: the tests execute right after their models, dbt exits non-zero on the failure,_runraises, the asset turns red and nothing downstream starts. Tests that run only when somebody remembers to type the command are not a gate. See Wire tests into the Dagster pipeline. -
The pipeline loads rentals once a day at 06:00 and checks source freshness at the top of each run, with
error_afterset to 24 hours. What happens on a morning when the run starts ten minutes late, and how should the threshold be chosen?Show answer
At the top of the run the newest
loaded_atis still yesterday's load — about 24 h 10 min old — so freshness errors before today's ingest has had a chance to run. A threshold equal to the load cadence has no slack. Choose it from the expected gap between loads plus headroom for ordinary delay: too tight and it cries wolf until people ignore it, too loose and stale data is served for hours unnoticed. Telemetry gets the tighter bound because it is expected continuously. See Freshness.
Exercise — Assert telemetry utilization is a valid percentage (15 minutes)
The fct_telemetry_hourly rollup has an avg_util_pct column — GPU utilization as a percentage. A percentage must sit between 0 and 100; anything outside that range is a bug (a bad reading, a divide error, a unit mix-up). Add a test that asserts avg_util_pct is always in [0, 100], run it, and confirm it passes.
Do it the singular-test way (one SQL file). If you want a bonus, also express the same rule the generic way using the accepted_range test from the dbt_utils package (add it to packages.yml and run uv run dbt deps first).
-- Fail if any hourly utilization falls outside 0..100.
-- Returns the offending rows; zero rows = pass.
select
gpu_id,
hour,
avg_util_pct
from {{ ref('fct_telemetry_hourly') }}
where avg_util_pct < 0
or avg_util_pct > 100 - name: fct_telemetry_hourly
columns:
- name: avg_util_pct
tests:
- dbt_utils.accepted_range:
arguments:
min_value: 0
max_value: 100
inclusive: trueuv run dbt test --select fct_telemetry_hourly
# -> PASS assert_util_pct_in_rangeTo really convince yourself, insert a reading with util_pct = 14200 into bronze.telemetry, rebuild the rollup, and watch this test fail with Got 1 result — then remove it and watch it pass. Same fail → fix → pass loop, now guarding your telemetry.
Takeaway
- A pipeline that runs is not one you can trust; add schema tests for shape, freshness checks for recency and singular tests for business rules.
uniqueplusnot_nullon every primary key catches the commonest warehouse bug, a fan-out join that doubles rows and inflates revenue, andrelationshipscatches orphaned facts.- Schema tests pass on stale data, so freshness compares the newest
loaded_atwith now to catch a feed that silently stopped. - A singular test is a SQL file that selects the bad rows, such as negative revenue, and a reconciliation test checks that every bronze rental reached the gold fact.
- A test you have never seen fail is not trusted, so inject a negative-hours row, watch the suite go red, then fix it and watch it go green.
- A dbt test failure blocks downstream because the whole
dbt buildfails, while a Dagster asset check only reports unless it is markedblocking=True.
With a trusted star schema you can define metrics once and serve them everywhere, which is the next lab. → Lab 07 — The Semantic Layer & Metrics
Further reading
- dbt: Data tests — dbt Labs docs, accessed 2026. Generic and singular tests and how failing rows are reported.
- dbt: Freshness — dbt Labs docs, accessed 2026. Warn and error thresholds on a source's load time.
- Dagster: Testing assets with asset checks — Dagster Labs docs, accessed 2026. Asset checks and the
blockingoption. - dbt: build command — dbt Labs docs, accessed 2026. Why a failed test stops downstream models.