Data Quality & Testing
You've modeled, moved, transformed, stored, streamed, and scheduled. None of it matters if people don't believe the numbers. This final chapter of the craft is about proof — building quality into the pipeline as a first-class system so that when a dashboard says "revenue: $48,200," everyone trusts it. It's the difference between a pipeline that runs and one people stake decisions on.
You'll extend the dbt marketplace project from Chapter 07. Open a terminal in that project (the folder with dbt_project.yml), confirm dbt build still runs clean, and keep this page beside it. Everything here is plain dbt and SQL — no new installs.
Trust is the product
Here's a hard truth that takes most engineers a painful incident to learn: the output of a data platform is not tables — it's trust. A pipeline can be elegantly modeled, perfectly orchestrated, and lightning fast, and still be worthless the moment a stakeholder catches a single wrong number.
And the damage is not local. When someone opens a dashboard, sees revenue that's obviously too high, and traces it to a duplicated row, they don't conclude "that one metric is broken." They conclude "I can't trust any of these numbers" — and they go back to their own spreadsheet. One bad cell poisons confidence in the whole platform, and rebuilding that confidence takes months. Trust is expensive to earn and cheap to destroy.
Beginners treat quality as something you "check" at the end — eyeball the output, looks fine, ship it. Professionals treat it as a system: explicit assertions that run automatically every time data moves, so bad data is caught by a machine before a human ever sees it. The goal is not "we looked carefully." The goal is "it is structurally impossible for this class of error to reach the dashboard." That mindset is the whole chapter.
The dimensions of quality
"Good data" sounds vague until you break it into the specific ways data goes bad. Each failure mode is a dimension of quality, and each one is something you can write an assertion about. Here's where they sit along the pipeline you already built:
Six dimensions cover the vast majority of real incidents. Learn them as a checklist — when something breaks, it's almost always one of these:
| Dimension | The question it answers | What it catches |
|---|---|---|
| Freshness | Is the data recent enough? | A stalled upstream job — yesterday's numbers shown as today's. |
| Volume | Is the row count in a normal range? | A half-loaded file (too few rows) or a duplicated load (too many). |
| Schema | Are the columns & types what we expect? | An upstream rename or dropped column that silently breaks a join. |
| Validity / values | Are individual values sane? | Negative revenue, a status of "banana", a NULL where a key must exist. |
| Uniqueness | Is each key represented once? | Duplicate rows that double-count and inflate every metric. |
| Referential integrity | Do foreign keys point to real rows? | A rental referencing a customer that doesn't exist — orphaned facts. |
Notice these map directly onto problems you've already seen: uniqueness is why deduplication mattered in the medallion silver layer; referential integrity is what dimensional modeling's keys are for. Testing isn't a new topic bolted on — it's verifying the very properties your modeling promised.
Testing in the pipeline
A test in data engineering is a query that returns the rows that violate a rule. Zero rows returned means the test passes; any rows returned means it fails. That's the entire mental model, and dbt builds on it. You met four built-in tests in Chapter 07 — here they are as the front line of defense:
models:
- name: fct_rentals
columns:
- name: rental_id
tests:
- not_null # every fact must have a key
- unique # …and it must appear exactly once
- name: customer_id
tests:
- not_null
- relationships: # referential integrity
to: ref('dim_customers')
field: customer_id
- name: status
tests:
- accepted_values: # validity — the only allowed values
values: ['active', 'completed', 'cancelled']Those four — not_null, unique, relationships, accepted_values — handle the common cases declaratively. But some rules don't fit a column dropdown. For those, dbt gives you two escape hatches.
A singular test is just a hand-written SQL file in tests/ that selects the bad rows. Want to assert revenue is never negative? Write the query that finds negatives:
-- Passes only if this returns zero rows.
-- Any row here is a rental with impossible revenue.
select
rental_id,
revenue
from {{ ref('fct_rentals') }}
where revenue < 0A generic test wraps that pattern in a reusable macro so you can apply it by name to any column (e.g. a non_negative test) — handy once you're writing the same check in several models. And separate from both is unit testing your transform logic: feeding a model a tiny set of fixed input rows and asserting the exact output, so you catch a broken CASE statement or off-by-one window function regardless of what today's real data happens to look like:
unit_tests:
- name: revenue_is_hours_times_rate
model: fct_rentals
given:
- input: ref('stg_rentals')
rows:
- {rental_id: 1, hours: 2, hourly_rate: 5.00}
expect:
rows:
- {rental_id: 1, revenue: 10.00} # 2 × 5.00They answer different questions. A data test asks "is today's data clean?" — it runs against whatever loaded. A unit test asks "is my code correct?" — it runs against fixed fake rows and would catch a logic bug even on a perfect day. Mature pipelines use both: unit tests guard the transform, data tests guard the data flowing through it.
The final piece is when tests run. Tests sitting in your repo that nobody runs are decoration. The professional move is to run dbt build (which runs models and their tests, stopping on failure) in CI — the automated check on every pull request, from your Git & GitHub chapter. Now a teammate literally cannot merge a change that breaks a test. Bad data is caught before it ships, not after a stakeholder reports it.
Data observability — the unknown-unknowns
Every test above guards a rule you thought of. But the failures that hurt most are the ones nobody anticipated: a vendor quietly changes a timezone, a marketing campaign triples sign-ups overnight, a column starts arriving as a string instead of a number. You can't write a test for a problem you haven't imagined.
Data observability fills that gap. Instead of asserting specific rules, observability tools watch your tables over time and learn what normal looks like, then alert when reality drifts:
| Tests (known-knowns) | Observability (unknown-unknowns) |
|---|---|
| You declare the rule explicitly. | The monitor learns the baseline automatically. |
"status must be in this list." | "This table usually gets ~10k rows/day; today it got 200 — that's anomalous." |
| Catches violations you predicted. | Catches drift you never thought to check. |
| Pass / fail, binary. | Anomaly score — "this looks off, take a look." |
Observability typically watches the same dimensions from the table above — freshness (did this table update on schedule?), volume (is today's row count within the normal band?), and schema (did columns or types change?) — but as automated monitors rather than hand-written assertions. When a metric strays far enough from its learned baseline, it fires an alert. You don't need a paid platform to start: even a scheduled query that logs daily row counts and flags a sudden 50% drop is observability in spirit.
Don't pick one. Tests encode the rules you're sure about and fail loud and early. Observability is the safety net underneath, catching the surprises. Together they cover both what you know to check and what you don't — the complete picture of "is this data okay right now?"
Data contracts — pushing quality left
Everything so far reacts to bad data after it enters your pipeline. The best quality strategy is to stop it at the door. That's a data contract: an explicit, versioned agreement with the team (or system) that produces a source you depend on.
A contract spells out, in writing and in code, three things about a source:
- Schema — the exact columns, types, and which fields are guaranteed non-null. (The
customer_idwill always be a non-null integer.) - Semantics — what the values mean. (
status = 'completed'means the rental ended and was billed — not "in progress".) - SLA — the freshness and availability promise. (This table updates by 06:00 UTC daily; we'll give 30 days' notice before any breaking change.)
The point of writing it down is that an upstream change now becomes a contract violation — something the producing team agreed not to do silently — instead of a surprise that breaks your dashboard at 2 a.m. Where tests catch bad data after it's loaded, a contract catches a breaking change before it's even published. That's what people mean by "shifting quality left": moving the check as close as possible to where data is produced, where it's cheapest to fix.
Picture the pipeline as a line, source on the left, dashboard on the right. A schema break caught at the source costs the producer a code review. The same break caught at the dashboard costs you a fire drill, a stakeholder's trust, and a backfill of every downstream table. Same bug, wildly different price — and the only variable is how far left you caught it.
Hands-on: add a test suite, then break it
Time to make this real. You'll add a suite of tests to the marketplace project, then deliberately introduce a bad row and watch the suite catch it — the full fail→fix→pass loop that defines test-driven data work.
Step 1 — declare the tests. Add (or extend) the schema file for your marts so the keys, the relationship, and the value set are all covered:
version: 2
models:
- name: fct_rentals
description: One row per GPU rental, with revenue.
columns:
- name: rental_id
tests: [not_null, unique]
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: status
tests:
- accepted_values:
values: ['active', 'completed', 'cancelled']Step 2 — add a freshness check. dbt's source freshness watches a raw source's load time. Point it at your rentals source and set a threshold:
sources:
- name: marketplace
tables:
- name: rentals
loaded_at_field: loaded_at # a timestamp column on the table
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 24, period: hour} # fail if no load in 24hStep 3 — run the suite on clean data. Run the tests and the freshness check:
dbt test # run all data tests
dbt source freshness # run the freshness checkEach test prints PASS, ending with a line like Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6, and freshness reports PASS. Green across the board — your data satisfies every rule you declared. Now let's prove the tests actually work by breaking one.
Step 4 — introduce a bad row. Insert a duplicate rental_id into the source so the unique test should catch it:
# duplicate an existing rental_id straight into the source table
docker compose exec -T postgres psql -U postgres -c \
"INSERT INTO rentals (rental_id, customer_id, status, hours, hourly_rate)
SELECT rental_id, customer_id, status, hours, hourly_rate
FROM rentals ORDER BY rental_id LIMIT 1;"
dbt build # rebuild models, then run their testsThe run goes red. Among the output you'll see something like:
FAIL 1 unique_fct_rentals_rental_id
Got 1 result, configured to fail if != 0
Done. PASS=5 WARN=0 ERROR=1 SKIP=0 TOTAL=6
This is the moment of value: a machine caught a duplicate that would have double-counted revenue, and it caught it before the number reached anyone. In CI, this exact failure would have blocked the pull request.
Step 5 — fix it and confirm green. Remove the duplicate and re-run:
# delete the duplicate, keeping one copy of each rental_id
docker compose exec -T postgres psql -U postgres -c \
"DELETE FROM rentals a USING rentals b
WHERE a.ctid < b.ctid AND a.rental_id = b.rental_id;"
dbt build # rebuild + re-testDone. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6. The duplicate is gone, the unique test passes again, and you've watched a single rule go fail→fix→pass. That red-to-green loop is exactly what runs on every commit once these tests live in CI — your platform now defends itself.
Wrapping up the craft
Take a moment, because you just finished something substantial. Look back at what's now sitting in your mini-griddp repo:
That is not a toy. It's every component of a real data platform — the same shape as systems running in production at companies you've heard of, just scaled to your laptop. You designed the data's shape, got it in reliably, cleaned it with version-controlled SQL, stored it in open formats, handled it as both batch and stream, scheduled it to run on its own, and proved it correct. The craft is no longer abstract; it's in your repo and it works.
Two courses remain, and they change register. Course 5 (Tooling) hardens the professional workflow around these components — environments, packaging, CI/CD, secrets, the engineering hygiene that turns "it runs on my machine" into "it runs reliably for a team." Course 6 (Capstone) then assembles everything you've built into one coherent, end-to-end system you can put your name on.
Before you leave the craft, commit the test suite to mini-griddp with a clear message — git commit -m "Add data quality tests to marketplace project". A repo with tests is a repo that signals seriousness. This is the work that makes a portfolio convincing.
✓ Check yourself
- Can you name the six dimensions of data quality, and give an incident each one catches?
- What's the difference between a data test and a unit test — which one would catch a logic bug on a day when the data is perfectly clean?
- Why do tests and observability complement each other rather than compete?
- What does a data contract do that a downstream test can't, and why is catching a problem "to the left" cheaper?
Exercise — Guard revenue and freshness (15 minutes)
Two assertions every revenue pipeline should have. (1) Write a singular test that fails if any rental has negative revenue. (2) Add a freshness check that fails if no rentals data loaded today. Then prove each one by introducing a violation, running it, and fixing it.
-- (1) Passes only if zero rows come back.
select rental_id, revenue
from {{ ref('fct_rentals') }}
where revenue < 0# (2) Fail if the newest load is more than a day old.
sources:
- name: marketplace
tables:
- name: rentals
loaded_at_field: loaded_at
freshness:
error_after: {count: 24, period: hour} # nothing loaded today -> ERROR# prove the revenue test catches a bad value
docker compose exec -T postgres psql -U postgres -c \
"UPDATE rentals SET hourly_rate = -5 WHERE rental_id = (SELECT min(rental_id) FROM rentals);"
dbt build # -> assert_revenue_non_negative FAILs (1 result)
# fix it and re-run
docker compose exec -T postgres psql -U postgres -c \
"UPDATE rentals SET hourly_rate = 5 WHERE hourly_rate < 0;"
dbt build # -> PASS again
# prove freshness: an old loaded_at trips the check
dbt source freshness # ERROR if loaded_at is older than 24hIf you wrote the negative-revenue query as "select the rows that violate the rule" without being told to, the core instinct of data testing has landed: a test is a query for the bad rows, and clean means none. That single idea generalizes to every assertion you'll ever write.
Next
That's a wrap on Course 4 — The DE Craft. You've built every component of a data platform with your own hands and proven it correct. Genuinely: well done. The next stop is Course 5 — Tooling & the Modern Stack, which hardens the professional workflow around everything you've built — and it's ready now. → Continue to Tooling