In this post we are going to cover testing and data quality in dbt: data tests, severity thresholds, singular and generic tests, unit tests, model contracts and source freshness. These are 7 real dbt interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario where your manager asks you to build a testing strategy for a project that has no tests today.
Testing is how a dbt project stops bad data before it reaches a dashboard. dbt gives you several tools for it, and each one catches a different kind of problem: data tests check the rows, unit tests check the logic, contracts check the shape, and freshness checks that data is still arriving. Interviewers ask about it because every production incident ends with the same question: which test would have caught this?
Part 4 of the Real Time dbt Interview Questions and Answers series on TechBrothersIT.
- The short answer
- Let's create the project and some sample data
- Question 1: Duplicate customer IDs reached the finance dashboard. Which dbt tests would have caught it, and how do you add them?
- Question 2: A test fails on three known bad rows from an old system. How do you use severity, warn_if and error_if instead of disabling the test?
- Question 3: The business rule is that refunds can never exceed the order amount. How do you write a singular test or a custom generic test?
- Question 4: A teammate changed a CASE statement and broke the revenue logic, but all data tests still passed. How do unit tests help?
- Question 5: A downstream team needs the columns and types of your model to never change by surprise. How do model contracts work?
- Question 6: Raw data stopped arriving and nobody noticed for two days. How do you set up source freshness?
- Scenario 7: Your manager asks you to build a testing strategy for a dbt project that has no tests today. Where do you start?
- The full script
The short answer
Put unique and not_null on every key and run dbt build, so a failing test skips everything downstream. Tune known issues with warn_if / error_if, write business rules as singular or generic tests, check logic with unit tests, protect shared models with contracts, and watch feeds with source freshness.
Let's create the project and some sample data
Our project is a small shop. A seed called raw_customers stands in for the raw customer table, with two problems already in it: customer 102 is in there twice, and three customers from the legacy system have no email. A staging view cleans it up and a mart table, dim_customers, feeds the dashboard. Here are the starting files. The full script at the end also writes a profiles.yml that points at a local DuckDB file.
name: shop
version: '1.0'
profile: shop
models:
shop:
staging:
+materialized: view
marts:
+materialized: tablecustomer_id,email,source_system,updated_at
101,ali@shop.com,crm,2026-09-01 10:00:00
102,sara@shop.com,crm,2026-09-02 11:00:00
102,sara.lee@shop.com,crm,2026-10-01 09:30:00
103,,legacy,2019-03-04 00:00:00
104,,legacy,2019-05-11 00:00:00
105,,legacy,2019-08-20 00:00:00
106,omar@shop.com,crm,2026-10-02 14:00:00select
customer_id,
email,
source_system,
cast(updated_at as timestamp) as updated_at
from {{ ref('raw_customers') }}select customer_id, email, source_system, updated_at
from {{ ref('stg_customers') }}Question 1: Duplicate customer IDs reached the finance dashboard. Which dbt tests would have caught it, and how do you add them?
The primary key tests: unique and not_null on customer_id. They are a few lines of YAML, under data_tests, in the model's properties file.
Let's add them to stg_customers and run dbt build on dim_customers and everything upstream of it.
models:
- name: stg_customers
columns:
- name: customer_id
data_tests:
- unique
- not_null$ dbt build -s +dim_customers
Succeeded seed main.raw_customers (table)
Succeeded model main.stg_customers (view)
Passed test not_null_stg_customers_customer_id
Failed test unique_stg_customers_customer_id (models/staging/stg_customers.yml:6:13)
Skipped model main.dim_customers (table)
====================== Test Failures =======================
Test failed (1 failed row(s)): unique_stg_customers_customer_id
Finished 'build' with 1 error for target 'dev'
Processed: 2 models | 2 tests | 1 seed
Summary: 5 total | 3 success | 1 error | 1 skippedThe unique test fails with 1 failed row(s): customer 102 is in the source twice. And because build runs the tests right after their model, dim_customers is skipped. The summary says 1 error | 1 skipped, so the duplicates never reach the dashboard.
Then we fix the cause. Staging now keeps the latest row per customer, with qualify and row_number(). Let's build again.
select
customer_id,
email,
source_system,
cast(updated_at as timestamp) as updated_at
from {{ ref('raw_customers') }}
-- one row per customer: keep the latest version
qualify row_number() over (
partition by customer_id order by updated_at desc
) = 1$ dbt build -s +dim_customers
Succeeded seed main.raw_customers (table)
Succeeded model main.stg_customers (view)
Passed test not_null_stg_customers_customer_id
Passed test unique_stg_customers_customer_id
Succeeded model main.dim_customers (table)
Finished 'build' successfully for target 'dev'
Processed: 2 models | 2 tests | 1 seed
Summary: 5 total | 5 successBoth tests pass and dim_customers builds: 5 total | 5 success.
On a real project I put unique and not_null on the key of every model, and relationships tests on the foreign keys. For a composite key, the dbt_utils package has unique_combination_of_columns.
Run dbt build, not dbt run and then dbt test. With build, a failing test stops bad data from flowing downstream.
Question 2: A test fails on three known bad rows from an old system. How do you use severity, warn_if and error_if instead of disabling the test?
Don't disable it. A disabled test also stops catching new bad rows. Set thresholds instead, so the known rows warn and anything new fails.
First, let's add a not_null test on email and build.
models:
- name: stg_customers
columns:
- name: customer_id
data_tests:
- unique
- not_null
- name: email
data_tests:
- not_null$ dbt build -s +dim_customers
Succeeded seed main.raw_customers (table)
Succeeded model main.stg_customers (view)
Passed test not_null_stg_customers_customer_id
Failed test not_null_stg_customers_email (models/staging/stg_customers.yml:10:13)
Passed test unique_stg_customers_customer_id
Skipped model main.dim_customers (table)
====================== Test Failures =======================
Test failed (3 failed row(s)): not_null_stg_customers_email
Finished 'build' with 1 error for target 'dev'
Processed: 2 models | 3 tests | 1 seed
Summary: 6 total | 4 success | 1 error | 1 skippedThe three legacy customers have no email, so the test fails with 3 failed row(s) and dim_customers is skipped. Now we set thresholds in the test's config. With severity: error, dbt checks error_if first: more than three failing rows is an error. Then warn_if: more than zero is a warning.
models:
- name: stg_customers
columns:
- name: customer_id
data_tests:
- unique
- not_null
- name: email
data_tests:
- not_null:
config:
severity: error
warn_if: '>0'
error_if: '>3'$ dbt build -s +dim_customers
Succeeded seed main.raw_customers (table)
Succeeded model main.stg_customers (view)
Passed test not_null_stg_customers_customer_id
Warned test not_null_stg_customers_email (models/staging/stg_customers.yml:10:13)
Passed test unique_stg_customers_customer_id
Succeeded model main.dim_customers (table)
Finished 'build' with 1 warning for target 'dev'
Processed: 2 models | 3 tests | 1 seed
Summary: 6 total | 5 success | 1 warnThe test now shows Warned on the three known rows, and dim_customers builds. The build finishes with 1 warning. Then a fourth bad row arrives: customer 107, from the CRM, without an email.
customer_id,email,source_system,updated_at
101,ali@shop.com,crm,2026-09-01 10:00:00
102,sara@shop.com,crm,2026-09-02 11:00:00
102,sara.lee@shop.com,crm,2026-10-01 09:30:00
103,,legacy,2019-03-04 00:00:00
104,,legacy,2019-05-11 00:00:00
105,,legacy,2019-08-20 00:00:00
106,omar@shop.com,crm,2026-10-02 14:00:00
107,,crm,2026-10-03 08:00:00$ dbt build -s +dim_customers
Succeeded seed main.raw_customers (table)
Succeeded model main.stg_customers (view)
Passed test not_null_stg_customers_customer_id
Failed test not_null_stg_customers_email (models/staging/stg_customers.yml:10:13)
Passed test unique_stg_customers_customer_id
Skipped model main.dim_customers (table)
====================== Test Failures =======================
Test failed (4 failed row(s)): not_null_stg_customers_email
Finished 'build' with 1 error for target 'dev'
Processed: 2 models | 3 tests | 1 seed
Summary: 6 total | 4 success | 1 error | 1 skippedThe test fails again, with 4 failed row(s), and dim_customers is skipped. That is exactly what we want.
There are two more options worth knowing:
- A
whereconfig that excludes the legacy rows is often better: any new null fails, even if an old row was fixed in the meantime. severity: warnon its own never fails, because dbt then skipserror_if.
Question 3: The business rule is that refunds can never exceed the order amount. How do you write a singular test or a custom generic test?
A singular test is a SQL file in the tests folder that returns the bad rows. A custom generic test is a reusable test block that you attach to columns in YAML. Zero rows returned means the test passes, for both.
Let's start with the generic test. The not_greater_than test block takes the model, the column and our own argument, other_column. We attach it under arguments: refund_amount must not be greater than order_amount. We add orders and refunds, build fct_refunds, and look at the refunds.
order_id,customer_id,amount,status
1,101,100.00,completed
2,102,50.00,shipped
3,106,75.00,cancelledrefund_id,order_id,refund_amount
1,1,30.00
2,1,80.00
3,2,60.00
4,3,20.00select
order_id,
customer_id,
cast(amount as decimal(10, 2)) as amount,
status
from {{ ref('raw_orders') }}select
r.refund_id,
r.order_id,
cast(r.refund_amount as decimal(10, 2)) as refund_amount,
o.amount as order_amount
from {{ ref('raw_refunds') }} as r
join {{ ref('stg_orders') }} as o on r.order_id = o.order_id{% test not_greater_than(model, column_name, other_column) %}
select *
from {{ model }}
where {{ column_name }} > {{ other_column }}
{% endtest %}models:
- name: fct_refunds
columns:
- name: refund_amount
data_tests:
- not_greater_than:
arguments:
other_column: order_amount$ dbt build -s +fct_refunds
Succeeded seed main.raw_orders (table)
Succeeded seed main.raw_refunds (table)
Succeeded model main.stg_orders (view)
Succeeded model main.fct_refunds (table)
Failed test not_greater_than_fct_refunds_refund_amount__order_amount
(models/marts/fct_refunds.yml:6:13)
====================== Test Failures =======================
Test failed (1 failed row(s)): not_greater_than_fct_refunds_refund_amount__order_amount
Finished 'build' with 1 error for target 'dev'
Processed: 2 models | 1 test | 2 seeds
Summary: 5 total | 4 success | 1 errorselect refund_id, order_id, refund_amount, order_amount from fct_refunds order by refund_id| refund_id | order_id | refund_amount | order_amount |
|---|---|---|---|
| 1 | 1 | 30.00 | 100.00 |
| 2 | 1 | 80.00 | 100.00 |
| 3 | 2 | 60.00 | 50.00 |
| 4 | 3 | 20.00 | 75.00 |
The generic test finds one row: refund 3, 60.00 on an order of 50.00. Now the singular test. It totals the refunds per order and returns the orders where they add up to more than the order amount. We run it with dbt test.
-- all refunds of an order must not add up to more than the order
select
order_id,
sum(refund_amount) as total_refunded,
max(order_amount) as order_amount
from {{ ref('fct_refunds') }}
group by order_id
having sum(refund_amount) > max(order_amount)$ dbt test -s assert_refunds_not_more_than_order
Failed test assert_refunds_not_more_than_order (tests/assert_refunds_not_more_than_order.sql)
====================== Test Failures =======================
Test failed (2 failed row(s)): assert_refunds_not_more_than_order
Finished 'test' with 1 error for target 'dev'
Processed: 1 test
Summary: 1 total | 1 errorIt finds 2 failed row(s). Order 1 had two refunds, 30.00 and 80.00. Each one passes on its own, but together they add up to 110, more than the 100.00 order.
That is how I choose:
- A one-off rule, or one that needs joins or totals: a singular test
- A rule you will reuse on many columns: a generic test
Both kinds take the same configs, like severity and store_failures, and both run with dbt build.
Question 4: A teammate changed a CASE statement and broke the revenue logic, but all data tests still passed. How do unit tests help?
Data tests check the data after the build. A unit test checks the logic itself, with fixed inputs and expected outputs. A not_null test on revenue still passes when the CASE is wrong, because zero is not null.
The unit test lives in YAML, next to the model. Given three staging rows, completed, shipped and cancelled, it expects revenue of 100, 50 and 0. You only list the columns that matter. Let's build fct_orders.
select
order_id,
customer_id,
status,
case
when status in ('completed', 'shipped') then amount
else 0
end as revenue
from {{ ref('stg_orders') }}models:
- name: fct_orders
columns:
- name: revenue
data_tests:
- not_null
unit_tests:
- name: revenue_counts_completed_and_shipped
model: fct_orders
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, amount: 100, status: completed}
- {order_id: 2, amount: 50, status: shipped}
- {order_id: 3, amount: 75, status: cancelled}
expect:
rows:
- {order_id: 1, revenue: 100}
- {order_id: 2, revenue: 50}
- {order_id: 3, revenue: 0}$ dbt build -s +fct_orders
Succeeded seed main.raw_orders (table)
Succeeded model main.stg_orders (view)
Passed unit_test main_dbt_test__audit.revenue_counts_completed_and_shipped
Succeeded model main.fct_orders (table)
Passed test not_null_fct_orders_revenue
Finished 'build' successfully for target 'dev'
Processed: 2 models | 1 test | 1 seed | 1 unit test
Summary: 5 total | 5 successThe unit test and the data test both pass. Now the teammate changes the CASE to count only completed orders, and we build again.
select
order_id,
customer_id,
status,
case
when status = 'completed' then amount
else 0
end as revenue
from {{ ref('stg_orders') }}$ dbt build -s +fct_orders
Succeeded seed main.raw_orders (table)
Succeeded model main.stg_orders (view)
Failed unit_test main_dbt_test__audit.revenue_counts_completed_and_shipped
(models/marts/fct_orders.yml:9:11)
Skipped model main.fct_orders (table)
Skipped test not_null_fct_orders_revenue (models/marts/fct_orders.yml:6:13)
====================== Test Failures =======================
Test failed: revenue_counts_completed_and_shipped
+----------+-------------+
| order_id | revenue |
+----------+-------------+
| 1 | 100.0 |
| 2 | 50.0 -> 0.0 |
| 3 | 0.0 |
+----------+-------------+
3 row(s) differ.
Finished 'build' with 1 error for target 'dev'
Processed: 2 models | 1 test | 1 seed | 1 unit test
Summary: 5 total | 2 success | 1 error | 2 skippedThe unit test fails, and the diff points at order 2: 50.0 -> 0.0. The unit test runs before the model is built, so fct_orders is skipped, and so is its not_null test. The summary says 1 error | 2 skipped.
One detail on ordering. The v2 docs say a build runs all unit tests first, using column type awareness. Here, on the dbt OSS build, as in dbt v1, each unit test runs just before its model, and its parents must exist.
dbt recommends running unit tests in development and CI, not in production. In prod, exclude them with --exclude-resource-type unit_test.
Question 5: A downstream team needs the columns and types of your model to never change by surprise. How do model contracts work?
A contract makes the shape of the model a promise. You set contract: enforced: true and declare every column with its data type. Constraints, like not_null, go into the table's DDL.
Let's add a contract to dim_customers and build it.
models:
- name: dim_customers
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: integer
constraints:
- type: not_null
- name: email
data_type: varchar
- name: source_system
data_type: varchar
- name: updated_at
data_type: timestamp$ dbt build -s dim_customers
Succeeded model main.dim_customers (table)
Finished 'build' successfully for target 'dev'
Processed: 1 model
Summary: 1 total | 1 successIt passes. dbt checks the columns of the query against the contract before it creates the table. Now a teammate casts customer_id to text.
select
cast(customer_id as varchar) as customer_id,
email,
source_system,
updated_at
from {{ ref('stg_customers') }}$ dbt build -s dim_customers
Failed model main.dim_customers (table)
=================== Errors and Warnings ====================
[error] [JinjaError (dbt1501)]: Error executing materialization macro
'dbt_duckdb.materialization_table_duckdb' for model model.shop.dim_customers: Failed to eval the
compiled Jinja expression invalid operation: Compilation Error for model.shop.dim_customers from
models/marts/dim_customers.sql: This model has an enforced contract that failed.
Please ensure the name, data_type, and number of columns in your contract match the columns in your
model's definition.
| column_name | definition_type | contract_type | mismatch_reason |
| ----------- | --------------- | ------------- | ------------------ |
| customer_id | text | integer | data type mismatch |
(in run/shop/models/marts/dim_customers.sql)
(in dbt_internal_packages/dbt-duckdb/macros/materializations/table.sql:39:11)
(in dbt_internal_packages/dbt-adapters/macros/etc/statement.sql:7:29)
(in dbt_internal_packages/dbt-duckdb/macros/materializations/table.sql:40:5)
(in dbt_internal_packages/dbt-adapters/macros/relations/table/create.sql:17:5)
(in dbt_internal_packages/dbt-duckdb/macros/adapters.sql:69:7)
(in dbt_internal_packages/dbt-adapters/macros/relations/column/columns_spec_ddl.sql:28:3)
(in dbt_internal_packages/dbt-adapters/macros/relations/column/columns_spec_ddl.sql:33:13)
(in dbt_internal_packages/dbt-adapters/macros/relations/column/columns_spec_ddl.sql:76:24)
--> run/shop/models/marts/dim_customers.sql
Finished 'build' with 1 error for target 'dev'
Processed: 1 model
Summary: 1 total | 1 errorThe build fails before the table is replaced, with This model has an enforced contract that failed. The mismatch table says it all: for customer_id the definition type is text, the contract type is integer, and the reason is data type mismatch. The downstream team never sees the change.
A few limits to know:
- Contracts work on tables and incremental models (with
on_schema_changeset tofailorappend_new_columns) - On views they check only names and types, with no constraints
- They don't work on ephemeral models
- Most platforms only enforce
not_null, so tests still check the content
When you really must change the shape, add a new model version, so consumers can move over on their own schedule. We cover versions later in this series.
Question 6: Raw data stopped arriving and nobody noticed for two days. How do you set up source freshness?
Source freshness compares the newest load time in a source table with the current time, and warns or errors when the gap gets too big.
First, we need a feed that has gone quiet. A small model stands in for the loader: the last payments batch landed two days ago. We run it with dbt run.
-- simulates the loader: the last batch landed two days ago
select
1 as payment_id,
1 as order_id,
100.00 as amount,
current_timestamp - interval 2 day as _loaded_at$ dbt run -s payments_feed
Succeeded model main.payments_feed (view)
Finished 'run' successfully for target 'dev'
Processed: 1 model
Summary: 1 total | 1 successThen we declare the source. In the source YAML, under config, we set the loaded_at_field and the thresholds: warn after 12 hours, error after 1 day. Let's run dbt source freshness.
sources:
- name: shop_app
schema: main
tables:
- name: payments_feed
config:
loaded_at_field: _loaded_at
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 1, period: day}$ dbt source freshness
Stale source shop_app.payments_feed (last updated 2days ago)
Successfully wrote sources.json
=================== Errors and Warnings ====================
[error] [StaleSource (dbt1063)]: Stale source source.shop.shop_app.payments_feed
--> models/staging/sources.yml
Finished 'freshness' with 1 error for target 'dev'The payments feed shows Stale (last updated 2days ago), and the command ends with StaleSource (dbt1063) and 1 error.
Two details interviewers like:
freshnessmoved underconfigin dbt v1.9, andloaded_at_fieldin v1.10. The dbt v2 parser rejects freshness at the old top level.- On DuckDB you need a
loaded_at_fieldor aloaded_at_query, because the adapter has no metadata-based freshness.
dbt v2 also adds model freshness and the dbt freshness command, for sources and models together. dbt source freshness still works.
Run freshness as the first step of the job, and alert on it, so a stopped feed pages someone the same morning, not two days later.
Scenario 7: Your manager asks you to build a testing strategy for a dbt project that has no tests today. Where do you start?
Start with what breaks dashboards most: keys. Then build out in layers, and run the tests everywhere.
Here is the order I go in:
- Keys:
uniqueandnot_nullon the primary key of every model, thennot_nullandaccepted_valueson the columns reports filter on - Sources: freshness on every feed, and key tests on the raw tables, so bad data is caught at the door
- Business rules: singular and generic tests
- Logic and shape: unit tests for tricky logic, contracts on the models other teams use
- Run it everywhere:
dbt buildin CI and production
Then set project defaults in dbt_project.yml. Here store_failures is on for every test, and customer 107 now has an email. Let's build the seed and staging again.
name: shop
version: '1.0'
profile: shop
models:
shop:
staging:
+materialized: view
marts:
+materialized: table
data_tests:
shop:
+store_failures: truecustomer_id,email,source_system,updated_at
101,ali@shop.com,crm,2026-09-01 10:00:00
102,sara@shop.com,crm,2026-09-02 11:00:00
102,sara.lee@shop.com,crm,2026-10-01 09:30:00
103,,legacy,2019-03-04 00:00:00
104,,legacy,2019-05-11 00:00:00
105,,legacy,2019-08-20 00:00:00
106,omar@shop.com,crm,2026-10-02 14:00:00
107,dina@shop.com,crm,2026-10-03 08:00:00$ dbt build -s raw_customers stg_customers
Succeeded seed main.raw_customers (table)
Succeeded model main.stg_customers (view)
Passed test main_dbt_test__audit.not_null_stg_customers_customer_id
Warned test main_dbt_test__audit.not_null_stg_customers_email
(models/staging/stg_customers.yml:10:13)
Passed test main_dbt_test__audit.unique_stg_customers_customer_id
Finished 'build' with 1 warning for target 'dev'
Processed: 1 model | 3 tests | 1 seed
Summary: 5 total | 4 success | 1 warnselect customer_id, source_system from main_dbt_test__audit.not_null_stg_customers_email order by customer_id| customer_id | source_system |
|---|---|
| 103 | legacy |
| 104 | legacy |
| 105 | legacy |
The build still passes, with 1 warning, and the failing rows are saved in a table in the main_dbt_test__audit schema. The query on that table returns the three legacy customers, 103, 104 and 105. That is the list you hand to the source owner.
Finally, in CI run dbt build -s state:modified+ on the changed models and their children, and run dbt build in production, so a failing test stops bad data.
After every incident, add the test that would have caught it.
Key points to remember
- unique + not_null on every key; dbt build skips downstream
- warn_if / error_if for known bad rows, never disable
- Singular tests for totals and joins, generic tests to reuse
- Unit tests for logic; contracts for the shape
- Source freshness first in the job, with an alert
The full script
Here is a Python script that writes the whole project and runs every dbt command above in order; run pip install dbt first.
Show the full script that builds this project and runs every step
# ========================================================================
# dbt Interview Q&A #4 - Testing and data quality
# TechBrothersIT. Sample data is invented for teaching.
# Every step below was run with dbt v2 (dbt OSS 2.0.8) on DuckDB; the outputs in the video and the
# blog post are the real outputs. To run it yourself, in an empty folder:
# pip install dbt (dbt v2; adapters are bundled, and DuckDB needs no account or server)
# python dbt_Interview_QA_04_Code.py
# ========================================================================
import os, subprocess
def write(path, text):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text.strip("\n") + "\n")
def remove(path):
if os.path.exists(path):
os.remove(path)
def dbt(cmd, expect_error=False):
print("\n$ " + cmd, flush=True)
rc = subprocess.run(cmd, shell=True).returncode
if rc != 0 and not expect_error:
raise SystemExit("stopped: this command was expected to succeed")
# Step 0: the project and its sample data
write('dbt_project.yml', """\
name: shop
version: '1.0'
profile: shop
models:
shop:
staging:
+materialized: view
marts:
+materialized: table
""")
write('seeds/raw_customers.csv', """\
customer_id,email,source_system,updated_at
101,ali@shop.com,crm,2026-09-01 10:00:00
102,sara@shop.com,crm,2026-09-02 11:00:00
102,sara.lee@shop.com,crm,2026-10-01 09:30:00
103,,legacy,2019-03-04 00:00:00
104,,legacy,2019-05-11 00:00:00
105,,legacy,2019-08-20 00:00:00
106,omar@shop.com,crm,2026-10-02 14:00:00
""")
write('models/staging/stg_customers.sql', """\
select
customer_id,
email,
source_system,
cast(updated_at as timestamp) as updated_at
from {{ ref('raw_customers') }}
""")
write('models/marts/dim_customers.sql', """\
select customer_id, email, source_system, updated_at
from {{ ref('stg_customers') }}
""")
write('profiles.yml', """\
shop:
target: dev
outputs:
dev:
type: duckdb
path: dev.duckdb
threads: 1
""")
# Step 1: Primary key tests on stg_customers: the duplicate customer fails the build
write('models/staging/stg_customers.yml', """\
models:
- name: stg_customers
columns:
- name: customer_id
data_tests:
- unique
- not_null
""")
dbt('dbt build -s +dim_customers', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 2: Fix the cause: keep the latest row per customer in staging
write('models/staging/stg_customers.sql', """\
select
customer_id,
email,
source_system,
cast(updated_at as timestamp) as updated_at
from {{ ref('raw_customers') }}
-- one row per customer: keep the latest version
qualify row_number() over (
partition by customer_id order by updated_at desc
) = 1
""")
dbt('dbt build -s +dim_customers')
# Step 3: A not_null test on email fails on three legacy rows
write('models/staging/stg_customers.yml', """\
models:
- name: stg_customers
columns:
- name: customer_id
data_tests:
- unique
- not_null
- name: email
data_tests:
- not_null
""")
dbt('dbt build -s +dim_customers', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 4: warn_if and error_if: the three known rows warn, more than three fail
write('models/staging/stg_customers.yml', """\
models:
- name: stg_customers
columns:
- name: customer_id
data_tests:
- unique
- not_null
- name: email
data_tests:
- not_null:
config:
severity: error
warn_if: '>0'
error_if: '>3'
""")
dbt('dbt build -s +dim_customers')
# Step 5: A fourth customer without an email arrives: the test fails again
write('seeds/raw_customers.csv', """\
customer_id,email,source_system,updated_at
101,ali@shop.com,crm,2026-09-01 10:00:00
102,sara@shop.com,crm,2026-09-02 11:00:00
102,sara.lee@shop.com,crm,2026-10-01 09:30:00
103,,legacy,2019-03-04 00:00:00
104,,legacy,2019-05-11 00:00:00
105,,legacy,2019-08-20 00:00:00
106,omar@shop.com,crm,2026-10-02 14:00:00
107,,crm,2026-10-03 08:00:00
""")
dbt('dbt build -s +dim_customers', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 6: Refunds: a custom generic test, not_greater_than
write('seeds/raw_orders.csv', """\
order_id,customer_id,amount,status
1,101,100.00,completed
2,102,50.00,shipped
3,106,75.00,cancelled
""")
write('seeds/raw_refunds.csv', """\
refund_id,order_id,refund_amount
1,1,30.00
2,1,80.00
3,2,60.00
4,3,20.00
""")
write('models/staging/stg_orders.sql', """\
select
order_id,
customer_id,
cast(amount as decimal(10, 2)) as amount,
status
from {{ ref('raw_orders') }}
""")
write('models/marts/fct_refunds.sql', """\
select
r.refund_id,
r.order_id,
cast(r.refund_amount as decimal(10, 2)) as refund_amount,
o.amount as order_amount
from {{ ref('raw_refunds') }} as r
join {{ ref('stg_orders') }} as o on r.order_id = o.order_id
""")
write('tests/generic/not_greater_than.sql', """\
{% test not_greater_than(model, column_name, other_column) %}
select *
from {{ model }}
where {{ column_name }} > {{ other_column }}
{% endtest %}
""")
write('models/marts/fct_refunds.yml', """\
models:
- name: fct_refunds
columns:
- name: refund_amount
data_tests:
- not_greater_than:
arguments:
other_column: order_amount
""")
dbt('dbt build -s +fct_refunds', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 7: Refunds: a singular test that totals the refunds per order
write('tests/assert_refunds_not_more_than_order.sql', """\
-- all refunds of an order must not add up to more than the order
select
order_id,
sum(refund_amount) as total_refunded,
max(order_amount) as order_amount
from {{ ref('fct_refunds') }}
group by order_id
having sum(refund_amount) > max(order_amount)
""")
dbt('dbt test -s assert_refunds_not_more_than_order', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 8: A unit test for the revenue CASE logic
write('models/marts/fct_orders.sql', """\
select
order_id,
customer_id,
status,
case
when status in ('completed', 'shipped') then amount
else 0
end as revenue
from {{ ref('stg_orders') }}
""")
write('models/marts/fct_orders.yml', """\
models:
- name: fct_orders
columns:
- name: revenue
data_tests:
- not_null
unit_tests:
- name: revenue_counts_completed_and_shipped
model: fct_orders
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, amount: 100, status: completed}
- {order_id: 2, amount: 50, status: shipped}
- {order_id: 3, amount: 75, status: cancelled}
expect:
rows:
- {order_id: 1, revenue: 100}
- {order_id: 2, revenue: 50}
- {order_id: 3, revenue: 0}
""")
dbt('dbt build -s +fct_orders')
# Step 9: A teammate changes the CASE: the unit test fails, the data test would still pass
write('models/marts/fct_orders.sql', """\
select
order_id,
customer_id,
status,
case
when status = 'completed' then amount
else 0
end as revenue
from {{ ref('stg_orders') }}
""")
dbt('dbt build -s +fct_orders', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 10: An enforced contract on dim_customers
write('models/marts/dim_customers.yml', """\
models:
- name: dim_customers
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: integer
constraints:
- type: not_null
- name: email
data_type: varchar
- name: source_system
data_type: varchar
- name: updated_at
data_type: timestamp
""")
dbt('dbt build -s dim_customers')
# Step 11: A teammate casts customer_id to text: the contract stops the build
write('models/marts/dim_customers.sql', """\
select
cast(customer_id as varchar) as customer_id,
email,
source_system,
updated_at
from {{ ref('stg_customers') }}
""")
dbt('dbt build -s dim_customers', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 12: Simulate the loader: the payments feed last landed two days ago
write('models/loader/payments_feed.sql', """\
-- simulates the loader: the last batch landed two days ago
select
1 as payment_id,
1 as order_id,
100.00 as amount,
current_timestamp - interval 2 day as _loaded_at
""")
dbt('dbt run -s payments_feed')
# Step 13: Source freshness: warn after 12 hours, error after 1 day
write('models/staging/sources.yml', """\
sources:
- name: shop_app
schema: main
tables:
- name: payments_feed
config:
loaded_at_field: _loaded_at
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 1, period: day}
""")
dbt('dbt source freshness', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 14: Project defaults: store_failures for every test, then rebuild
write('dbt_project.yml', """\
name: shop
version: '1.0'
profile: shop
models:
shop:
staging:
+materialized: view
marts:
+materialized: table
data_tests:
shop:
+store_failures: true
""")
write('seeds/raw_customers.csv', """\
customer_id,email,source_system,updated_at
101,ali@shop.com,crm,2026-09-01 10:00:00
102,sara@shop.com,crm,2026-09-02 11:00:00
102,sara.lee@shop.com,crm,2026-10-01 09:30:00
103,,legacy,2019-03-04 00:00:00
104,,legacy,2019-05-11 00:00:00
105,,legacy,2019-08-20 00:00:00
106,omar@shop.com,crm,2026-10-02 14:00:00
107,dina@shop.com,crm,2026-10-03 08:00:00
""")
dbt('dbt build -s raw_customers stg_customers')
Part 5: Snapshots and SCD Type 2
The sample data on this page is invented for teaching. Every terminal output is the real output of the dbt command above it, run with dbt v2 (dbt OSS 2.0.8) on DuckDB; run times and node counters are left out, and node lines are listed in DAG order. Part of the Real Time dbt Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of October 2026; check the official dbt documentation (docs.getdbt.com) for anything you quote in an interview.



No comments:
Post a Comment
Note: Only a member of this blog may post a comment.