Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT

Testing: Generic and Singular Tests

Generic tests versus singular tests, the four built-in generic tests and their exact YAML syntax, how a generic test actually works as a parameterized SQL query, writing custom generic and singular tests, and where in the DAG to place each kind of test.

65 min September 2026
// Part 01 — Why dbt Tests Exist

A dbt Model Is an Assertion Until Something Tests It

A dbt model is just a SELECT statement. It compiles, it runs, and it produces a table or view — none of which tells you anything about whether the data in that table is actually correct. A model can run successfully every single day for months while quietly producing duplicate primary keys, unexpected null values, or foreign keys that point nowhere, because "the SQL executed without error" and "the resulting data is correct" are completely different claims. dbt's testing framework exists to close that gap — to let you write down, in SQL or in YAML, the assumptions your models depend on, and have dbt check them automatically every time the models run.

Without tests, data quality problems are discovered downstream — by an analyst noticing a dashboard number looks wrong, or a stakeholder asking why a report doesn't match another one. With tests, the same problems are caught at the source, immediately after the model that introduced them runs, with a clear failure pointing at exactly which model and which assumption broke.

The mental model for every dbt test, generic or singular: a test is a SQL query that is expected to return zero rows. If it returns any rows at all, the test fails, and each returned row represents one specific record that violated the assertion. This single idea — "zero rows means passing, any rows means failing, and each row is a concrete example of the failure" — is the entire testing framework. Everything else is convenience built on top of it.

dbt draws a line between two kinds of tests: generic tests, which are reusable and parameterized and get applied to models and columns declaratively through YAML, and singular tests, which are one-off, fully custom SQL files written for a specific business rule that doesn't generalize. Part 02 covers generic tests in depth; Part 04 covers singular tests.

// Part 02 — Generic Tests

Generic Tests: Reusable, Parameterized, Defined Once

A generic test is a parameterized assertion you define once and apply to as many columns and models as you like, through YAML rather than by writing SQL every time. dbt ships with four built-in generic tests that cover the large majority of everyday data quality checks:unique, not_null, accepted_values, andrelationships.

unique — no column value appears more than once

models/marts/schema.yml — unique
models:
  - name: fct_orders
    columns:
      - name: order_id
        tests:
          - unique

This asserts that every value in order_id appears in the fct_orderstable at most once. It is the single most common test in any dbt project, because almost every model has some column — a primary key, a surrogate key — that is supposed to uniquely identify each row, and a duplicate there usually means an upstream join fanned out unexpectedly.

not_null — no null values in this column

models/marts/schema.yml — not_null
models:
  - name: fct_orders
    columns:
      - name: order_id
        tests:
          - unique
          - not_null
      - name: customer_id
        tests:
          - not_null

unique and not_null are almost always applied together on a primary key column — unique alone would still pass on a column full of nulls, since null values are not considered duplicates of each other by most warehouses' uniqueness semantics, sonot_null closes that gap.

accepted_values — this column can only ever contain values from a fixed list

models/marts/schema.yml — accepted_values
models:
  - name: fct_orders
    columns:
      - name: order_status
        tests:
          - accepted_values:
              values: ['placed', 'shipped', 'delivered', 'cancelled', 'refunded']

This is the right test for any column backed by a fixed, known set of states — an order status, a subscription tier, a shipping method. If the source system ever introduces a new status value that this model's downstream logic doesn't yet account for (a common real occurrence when an upstream team adds a new enum value without telling anyone), this test starts failing immediately rather than the new value silently falling through uncategorized in a dashboard.

relationships — a foreign-key-style referential integrity check

models/marts/schema.yml — relationships
models:
  - name: fct_orders
    columns:
      - name: customer_id
        tests:
          - relationships:
              to: ref('dim_customers')
              field: customer_id

This asserts that every customer_id value in fct_orders also exists as a customer_id value in dim_customers — the referential-integrity guarantee a traditional relational database would enforce with an actual foreign key constraint, re-created here as a dbt test because most analytical warehouses don't enforce foreign keys at the database level at all.

Generic testWhat it assertsTypical column
uniqueNo value in this column appears more than once.Primary keys, surrogate keys.
not_nullNo value in this column is null.Primary keys, required foreign keys, required business fields.
accepted_valuesEvery value in this column is one of a fixed, listed set.Status fields, categorical/enum-style columns.
relationshipsEvery value in this column exists as a value in another model's column.Foreign keys — customer_id, product_id, order_id references.
All four tests can be combined on one column
It is completely normal, and common, for one column — especially a foreign key likecustomer_id — to carry two or three tests at once: not_null plusrelationships is a frequent pairing, asserting both that the value is always present and that whenever it is present, it points to something real.

accepted_range and unique_combination_of_columns — the well-known package extensions

The four generic tests covered above ship with dbt itself and need no extra installation. Beyond them, the widely used dbt_utils package (a common addition to almost every real project, covered in a later module on packages) adds several more generic tests that fill common gaps — most notably accepted_range, for asserting a numeric column falls within a min/max bound, and unique_combination_of_columns, for asserting that a combination of several columns together is unique even though no single one of them is.

Two dbt_utils generic tests, for context (covered in the packages module)
models:
  - name: fct_orders
    columns:
      - name: total_amount
        tests:
          - dbt_utils.accepted_range:
              min_value: 0
              max_value: 100000

  - name: fct_order_line_items
    tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - order_id
            - line_item_id

These are worth knowing about even before covering the packages module in depth, becauseunique_combination_of_columns in particular is the standard way to express a composite uniqueness assertion — the built-in unique test only ever checks a single column, and a line-item level fact table's real primary key is almost always a combination of two or more columns rather than one.

// Part 03 — How a Generic Test Actually Works

Every Generic Test Is Just a Parameterized SQL Query

The YAML syntax in Part 02 can make generic tests feel like a special declarative feature disconnected from SQL. They are not. Under the hood, each one is a Jinja macro that compiles down to an ordinary SELECT statement, and dbt runs that statement and checks whether it returned any rows. Understanding this demystifies testing entirely — there is no magic, just SQL you would otherwise have had to write by hand, generated for you from a couple of YAML lines.

not_null is the clearest example: it is, quite literally,SELECT * FROM model WHERE column IS NULL. If that query returns zero rows, no column value was null, and the test passes. If it returns any rows, those are the exact rows with a null value, and the test fails.

What not_null compiles to, roughly
-- tests:
--   - not_null
-- applied to fct_orders.customer_id compiles to approximately:

select *
from analytics.fct_orders
where customer_id is null

-- Zero rows returned -> test PASSES
-- Any rows returned  -> test FAILS, and each returned row is a
--                        concrete example of a record with a null customer_id
What unique compiles to, roughly
-- tests:
--   - unique
-- applied to fct_orders.order_id compiles to approximately:

select order_id
from analytics.fct_orders
where order_id is not null
group by order_id
having count(*) > 1

-- Zero rows returned -> every order_id appears at most once -> PASSES
-- Any rows returned  -> those order_id values appear more than once -> FAILS
What accepted_values compiles to, roughly
-- tests:
--   - accepted_values:
--       values: ['placed', 'shipped', 'delivered', 'cancelled', 'refunded']
-- applied to fct_orders.order_status compiles to approximately:

select order_status
from analytics.fct_orders
where order_status not in ('placed', 'shipped', 'delivered', 'cancelled', 'refunded')

-- Any row returned is a value that snuck outside the accepted list
What relationships compiles to, roughly
-- tests:
--   - relationships:
--       to: ref('dim_customers')
--       field: customer_id
-- applied to fct_orders.customer_id compiles to approximately:

select fct_orders.customer_id
from analytics.fct_orders
left join analytics.dim_customers
  on fct_orders.customer_id = dim_customers.customer_id
where fct_orders.customer_id is not null
  and dim_customers.customer_id is null

-- Any row returned is a customer_id in fct_orders with no match in dim_customers

Once this clicks, a generic test stops looking like a special dbt-only concept and starts looking like exactly what it is: a SQL query you would have written anyway to sanity-check your data, wrapped in a small amount of Jinja so it can be parameterized by column name and reused across every model in the project without retyping the query each time.

dbt test failure output shows you the actual failing rows
Because every test is fundamentally "run this SELECT and show me what it returns," a failing test in dbt test output isn't just a pass/fail flag — dbt can show you a sample of the actual rows that violated the assertion, which is usually enough to diagnose the root cause without writing a single additional debugging query.

severity — not every failure needs to block a build

Every generic test also accepts a severity config, either error (the default) or warn. An error-severity test that fails causesdbt build to stop downstream models from building on top of it, exactly as covered in Part 06. A warn-severity test that fails is reported clearly in the run output but does not block anything downstream — useful for a check that is genuinely worth surfacing to a human but is not, on its own, severe enough to halt a production pipeline.

Configuring severity on a generic test
models:
  - name: fct_orders
    columns:
      - name: shipping_address
        tests:
          - not_null:
              config:
                severity: warn
                # a missing shipping address is worth flagging,
                # but should not block fct_orders itself, or
                # anything downstream, from building

A useful default heuristic: error for anything a broken downstream model or dashboard genuinely cannot tolerate — a duplicate primary key, a broken foreign key relationship — and warn for a data quality signal that is worth a human's attention but does not, by itself, invalidate everything built on top of the model.

// Part 04 — Singular Tests

Singular Tests: One-Off SQL for Rules That Don't Generalize

Not every business rule fits the generic-test mold of "check one column against one condition, reusable everywhere." Some assertions are specific to one model and one rule — "no order should ever have a negative total," "a subscription's end date should never be before its start date." These are singular tests: plain .sql files placed directly in the project'stests/ directory, each one a self-contained query that follows the exact same contract as a generic test — if it returns any rows, the test fails.

tests/assert_no_negative_order_totals.sql
-- A singular test: no filename-specific YAML wiring needed.
-- dbt discovers every .sql file in tests/ automatically and
-- runs it as a test. Failing means "this returned rows."

select
    order_id,
    total_amount
from {{ ref('fct_orders') }}
where total_amount < 0

There is no YAML required for a singular test at all — dbt automatically picks up every.sql file inside tests/ and treats it as a test named after the file. This one is named assert_no_negative_order_totals, and it will appear under that name in dbt test output.

tests/assert_subscription_dates_are_ordered.sql — a second example
-- Business rule specific to fct_subscriptions: end_date must never
-- be earlier than start_date. This rule doesn't generalize to any
-- other model in the project, so a singular test is the right fit
-- rather than trying to force it into a generic test.

select
    subscription_id,
    start_date,
    end_date
from {{ ref('fct_subscriptions') }}
where end_date < start_date
Generic testSingular test
DefinedOnce, as a macro; applied via YAML to many columns/models.Once, as a single .sql file for one specific rule.
ReusableYes — the same test (e.g. not_null) applies across the whole project.No — each file is a one-off, tied to one model's specific business rule.
ConfigurationDeclared in schema.yml under a model's columns.No YAML needed — dbt auto-discovers every .sql file in tests/.
Good forStructural checks: uniqueness, nullability, allowed values, foreign keys.Cross-column or cross-row business logic that doesn't generalize: date ordering, sign checks, multi-column consistency.
Both kinds obey the exact same zero-rows contract
A singular test is not a fundamentally different mechanism from a generic test — it is the same "return zero rows to pass" contract from Part 01, just written as a standalone SQL file instead of generated from a YAML-configured macro. If you can express a business rule as "rows where this bad condition holds," it can be a singular test.

A cross-model consistency check — a case singular tests handle well

Singular tests are also the natural fit for assertions that span more than one model — a comparison a generic test's single-model, single-column shape cannot express at all. A common real example: asserting that a fact table's total revenue for a period matches an independently computed total from a separate finance-reported summary table, catching a transformation bug that would never show up as a null, a duplicate, or an out-of-range value within either table alone.

tests/assert_revenue_matches_finance_summary.sql
-- Cross-model consistency check: dbt-computed daily revenue must
-- match the independently maintained finance summary table, within
-- a small rounding tolerance.

with dbt_computed as (
    select
        order_date,
        sum(total_amount) as dbt_revenue
    from {{ ref('fct_orders') }}
    group by 1
),

finance_reported as (
    select
        report_date as order_date,
        reported_revenue
    from {{ source('finance', 'daily_revenue_summary') }}
)

select
    dbt_computed.order_date,
    dbt_computed.dbt_revenue,
    finance_reported.reported_revenue,
    abs(dbt_computed.dbt_revenue - finance_reported.reported_revenue) as discrepancy
from dbt_computed
join finance_reported using (order_date)
where abs(dbt_computed.dbt_revenue - finance_reported.reported_revenue) > 1.00

No generic test could express this rule declaratively — it needs to join two entirely different models together and compare an aggregate across both, which is exactly the kind of one-off, cross-model logic singular tests exist for. This is also a good illustration of why singular tests are not a lesser or fallback option compared to generic tests — some genuinely important business rules can only be expressed this way.

// Part 05 — Writing a Custom Generic Test

Building Your Own Reusable Generic Test as a Macro

The four built-in generic tests cover the most common structural checks, but real projects regularly need a reusable assertion the built-ins don't cover — "this column must always be positive," "this timestamp column must never be in the future." When the same rule needs to apply to more than one column or model, writing it as a custom generic test avoids copy-pasting the same singular-test SQL over and over with only the column name changed.

A custom generic test is a macro following the naming convention test_<name>, placed in tests/generic/. It takes two implicit arguments provided by dbt for every generic test — model (the relation the test is applied to) andcolumn_name (the specific column, when the test is applied at the column level) — plus any additional parameters you define.

tests/generic/test_positive_value.sql
{% test positive_value(model, column_name) %}

select
    {{ column_name }}
from {{ model }}
where {{ column_name }} <= 0

{% endtest %}

Once this macro exists, positive_value can be applied to any column in any model, exactly like a built-in generic test:

models/marts/schema.yml — using the custom generic test
models:
  - name: fct_orders
    columns:
      - name: total_amount
        tests:
          - positive_value
  - name: fct_payments
    columns:
      - name: amount_paid
        tests:
          - positive_value

The same macro is reused across two different models and columns with zero duplication of the underlying SQL — precisely the payoff generic tests are built for. A more advanced custom generic test can accept additional configuration parameters beyond the implicitmodel and column_name, the same way accepted_valuesaccepts a values: list.

tests/generic/test_value_within_range.sql — a parameterized custom test
{% test value_within_range(model, column_name, min_value, max_value) %}

select
    {{ column_name }}
from {{ model }}
where {{ column_name }} < {{ min_value }}
   or {{ column_name }} > {{ max_value }}

{% endtest %}
Using value_within_range with its parameters
models:
  - name: stg_reviews
    columns:
      - name: star_rating
        tests:
          - value_within_range:
              min_value: 1
              max_value: 5
Reach for a custom generic test the moment you'd copy-paste a singular test
If you find yourself writing the same singular-test SQL twice with only a column name changed, that is the exact signal to promote it into a custom generic test instead — the same reasoning that drives extracting a repeated block of application code into a function.

Custom generic tests can reference other models too, not just the current column

The model and column_name arguments are just Jinja variables inside the macro — the query body can do anything a normal dbt model's SQL can do, including joining out to other tables via ref(). A common real pattern is a custom generic test checking a column's value against an aggregate computed from a separate model entirely, something none of the four built-in generic tests can express.

tests/generic/test_not_exceeding_daily_average.sql — referencing another model
{% test not_exceeding_daily_average(model, column_name, factor) %}

with stats as (
    select avg({{ column_name }}) as avg_value
    from {{ model }}
)

select
    {{ column_name }}
from {{ model }}, stats
where {{ column_name }} > stats.avg_value * {{ factor }}

{% endtest %}
Using it — flag any single order more than 10x the average order value
models:
  - name: fct_orders
    columns:
      - name: total_amount
        tests:
          - not_exceeding_daily_average:
              factor: 10

This kind of statistical outlier check is a genuinely useful complement to the four built-in structural tests — it does not catch a broken key or a null value, but it does catch a plausible but suspicious value, like an order total that is off by a decimal-place error upstream, that would otherwise sail through every structural test cleanly.

// Part 06 — Running Tests

dbt test, dbt build, and Scoping to One Model

dbt test runs every test defined in the project — every generic test declared in YAML and every singular test file in tests/ — and reports a pass/fail result for each one. This is the command a CI pipeline typically runs after dbt run to validate that everything just built is actually trustworthy.

Running the full test suite
dbt test
output
Running 14 tests
PASS unique_fct_orders_order_id ................................ [PASS in 0.42s]
PASS not_null_fct_orders_order_id .............................. [PASS in 0.31s]
PASS not_null_fct_orders_customer_id ............................ [PASS in 0.29s]
FAIL relationships_fct_orders_customer_id__customer_id__ref_dim_customers_
  Got 3 results, configured to fail if != 0 ....................... [FAIL 3 in 0.55s]
PASS accepted_values_fct_orders_order_status .................... [PASS in 0.38s]
...
Done. PASS=13 WARN=0 ERROR=0 FAIL=1 TOTAL=14

dbt test --select model_name scopes the run to only the tests attached to one specific model, which is useful while actively developing or debugging a single model instead of waiting for the entire project's test suite to run.

Scoping tests to one model
dbt test --select fct_orders

dbt build — models and their tests together, in dependency order

dbt run builds models. dbt test tests them. dbt build does both together, and — critically — in dependency order: it builds a model, immediately tests it, and only proceeds to build a downstream model if its upstream dependency's tests passed. This matters because a test failure on an upstream model should stop downstream models from building on top of data that has already been shown to be wrong.

Why ordering matters: dbt run + dbt test vs dbt build
# dbt run, then dbt test, as two separate steps:
# 1. dbt run builds EVERY model, including downstream ones,
#    even if an upstream model's data is actually broken
# 2. dbt test runs afterward and reports the failure --
#    but downstream models already built on top of the bad data

# dbt build, as one command:
# 1. builds stg_orders
# 2. tests stg_orders -- if this fails, dbt build stops here
#    for everything that depends on stg_orders
# 3. only if stg_orders' tests pass, proceeds to build fct_orders
# 4. tests fct_orders
# 5. only if fct_orders' tests pass, proceeds to models that ref() it

dbt build

This is the meaningful practical difference: dbt run followed by dbt testwill happily build every downstream model on top of upstream data that later turns out to have failed a test, because the test doesn't run until everything is already built. dbt buildcatches the failure at the point it happens and prevents anything downstream from compounding on bad data in the same invocation.

CommandWhat it doesWhen to use it
dbt runBuilds models only — no tests are run.Local iteration when you specifically only want to rebuild, not validate.
dbt testRuns tests only — assumes models are already built.Re-validating data quality without rebuilding anything, or CI validation after a separate build step.
dbt buildBuilds and tests every model, in dependency order, stopping downstream builds on an upstream test failure.The default choice for CI and production scheduled runs — the safest way to run the whole project.
dbt build is the production default for a reason
Most teams run dbt build, not dbt run, as their scheduled production job specifically because of the stop-on-failure ordering guarantee — it is the difference between catching a bad upstream row before it reaches a dashboard and finding out about it only after a stakeholder has already seen wrong numbers.

store_failures — keeping a queryable record of exactly what failed

By default, a failed test's result set is only shown transiently in the run's console output — it is not persisted anywhere. store_failures tells dbt to additionally write the failing rows to a real table in the warehouse, which is invaluable for a test that fails intermittently or whose failing rows are too numerous to usefully read from console output.

Persisting failing rows for later investigation
models:
  - name: fct_orders
    columns:
      - name: customer_id
        tests:
          - relationships:
              to: ref('dim_customers')
              field: customer_id
              config:
                store_failures: true
                schema: dbt_test_failures
output
FAIL relationships_fct_orders_customer_id__customer_id__ref_dim_customers_
  Got 47 results, configured to fail if != 0 ...................... [FAIL 47 in 1.2s]

  -- the 47 failing customer_id values are now persisted at:
  -- analytics.dbt_test_failures.relationships_fct_orders_customer_id__...
  -- queryable directly with ordinary SQL, no need to re-run the test

store_failures can be set project-wide in dbt_project.yml for every test, or scoped to just the tests worth the extra storage cost — typically the tests on the highest-traffic mart models, where a failure needs to be investigated by more than one person and a persisted, queryable record saves everyone from re-running the test just to see what broke.

// Part 07 — Where in the DAG to Put Which Tests

Testing Near the Source vs Testing at the Mart

Tests are not free to write or free to run, and not every test belongs at every layer of the DAG. Where a test lives changes what kind of problem it catches, and how early it catches it.

Source and staging-level tests — catching bad raw data early

Tests placed on sources and staging models catch structural problems in raw data as close to its origin as possible — a source table's primary key becoming non-unique, a required field starting to arrive null, a foreign key from an upstream system pointing at something that no longer exists. Catching this here means the problem is flagged before a single downstream model has had a chance to build on top of it.

models/staging/schema.yml — testing close to the source
sources:
  - name: raw_ecommerce
    tables:
      - name: orders
        columns:
          - name: order_id
            tests:
              - unique
              - not_null

models:
  - name: stg_orders
    columns:
      - name: order_id
        tests:
          - unique
          - not_null

Mart-level tests — catching business-logic bugs before they reach a dashboard

Tests placed on mart-level models — the fact and dimension tables that dashboards and reports actually query — catch a different category of problem: bugs introduced by the transformation logic itself, not by the raw source data. A join that fans out unexpectedly, an aggregation that double-counts a row, a business rule that was implemented slightly wrong — none of these would show up as a problem in the raw source data; they only appear once the model's own logic runs.

models/marts/schema.yml — testing at the mart level
models:
  - name: fct_orders
    columns:
      - name: order_id
        tests:
          - unique
          - not_null
      - name: customer_id
        tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: order_status
        tests:
          - accepted_values:
              values: ['placed', 'shipped', 'delivered', 'cancelled', 'refunded']

The unique test on order_id at the staging layer and the same test again at the mart layer are not redundant — they catch different failure modes. A duplicate at staging means the raw source itself has a data quality problem. A duplicate that only appears at the mart layer, despite staging being clean, means a join inside the transformation logic between staging and the mart fanned out and created duplicates that didn't exist in the source at all.

DAG layerWhat a failure there tells youExample test
SourceThe raw data landing from the upstream system is itself broken.not_null on a source table's required column.
StagingThe raw data is broken, or a light staging transformation (renaming, casting) introduced a problem.unique on a staging model's primary key.
Marts (facts/dimensions)The transformation logic itself — joins, aggregations, business rules — introduced a problem the raw data didn't have.relationships or accepted_values on a fact table's foreign key or status column.
This connects to source freshness and a fuller test strategy
Beyond structural tests on columns, a mature dbt project also checks source freshness — whether raw data is arriving on the schedule it's supposed to, not just whether it's structurally valid once it arrives. Building a complete, layered test strategy across an entire project — deciding exactly which tests belong at which layer, and how to triage a large volume of test failures — is its own discipline, covered in a later module in this track.

Intermediate-layer tests — a middle ground, used more sparingly

Between staging and marts, many projects have an intermediate layer of models — joins and aggregations that aren't yet the final, dashboard-facing fact or dimension table, but are more than a thin staging rename. Testing at this layer is usually more selective than at staging or marts: rather than testing every column, teams typically test only the specific transformation this intermediate model is responsible for, to pinpoint exactly which join or aggregation step introduced a problem when a downstream mart-level test eventually fails.

models/intermediate/schema.yml — a narrower, targeted test
models:
  - name: int_orders_joined_to_items
    columns:
      - name: order_id
        tests:
          - unique
          # Only testing uniqueness here, specifically because this is
          # the exact model where an order_items join could fan out an
          # order_id into multiple rows. Not every column needs a test
          # at every layer -- test where a specific risk actually lives.

This targeted approach avoids two failure modes at once: testing nothing at all in the middle of the DAG (which means a fan-out bug is only caught once it reaches the mart, with less precision about which step caused it), and testing every column at every layer (which multiplies the number of tests to maintain without a proportional increase in how quickly a real bug gets localized).

SignalWhere it usually points
staging-layer unique test failsThe raw source itself has duplicate rows — a source system bug, not a dbt transformation bug.
intermediate-layer unique test fails, staging passedA specific join at the intermediate layer fanned out unexpectedly — the exact model to inspect is identified directly by which test failed.
mart-layer unique test fails, intermediate passedA later aggregation or join, between the intermediate layer and the final mart, introduced the fan-out.
// Misconceptions

Five Misconceptions About dbt Testing

✕ ""Generic tests and singular tests are fundamentally different mechanisms in dbt""
Both obey the exact same contract: a SQL query that is expected to return zero rows, with any returned row representing one failing record. A generic test just gets that SQL generated from a reusable, parameterized macro applied through YAML, while a singular test is the same idea written directly as a one-off .sql file (Part 01, Part 03, Part 04).
✕ ""Passing dbt test means the data is correct""
Passing dbt test means the data satisfies whichever assertions were actually written down — nothing more. A model with zero tests configured will always pass trivially, and even a well-tested model can have real data quality problems that nobody thought to write a test for. Tests only catch violations of rules someone explicitly encoded.
✕ ""dbt run and dbt build do essentially the same thing""
dbt run only builds models, with no testing at all. dbt build builds AND tests every model in dependency order, stopping downstream models from being built on top of an upstream model whose tests just failed (Part 06) — a meaningfully different guarantee, not just a convenience wrapper.
✕ ""unique alone is enough to guarantee a column has no problems""
unique only checks for duplicates among non-null values on most warehouses' semantics — a column full of nulls can pass a unique test while being completely useless as a key. unique is almost always paired with not_null specifically to close this gap (Part 02).
✕ ""Testing only needs to happen at the mart level, since that's what dashboards actually query""
Testing only at the mart level means a bug is caught after transformation logic has already run, and gives no way to distinguish "the raw source data was bad" from "the transformation logic introduced the problem." Testing at both the source/staging layer and the mart layer catches different failure modes and localizes exactly where a problem was introduced (Part 07).
// Real World
💼 What This Looks Like at Work

Three Test Failures That Caught Real Bugs Before They Reached a Dashboard

Instacart — grocery delivery, order fact table

A new join is added to fct_orders at Instacart to bring in a promotions table, attaching promo codes to orders. The join key, order_id, is assumed to be unique on the promotions side — but a subset of orders had two promo codes stacked, which the promotions table represented as two separate rows per order.

The unique test on fct_orders.order_id, already sitting inschema.yml from before the promotions join was added, fails on the very firstdbt build after the change — flagging exactly which order IDs now appeared twice. Because the test lives at the mart layer, it immediately localizes the bug to the newly added join rather than the raw orders data, which was untouched and still passed its own staging-levelunique test cleanly.

Toast — restaurant point-of-sale, refund handling

An engineer at Toast writes a singular test asserting that no transaction'snet_amount (the charged amount minus any refunded amount) should ever be negative — a refund should never exceed the original charge. The test passes for months, until a partial refund workflow is changed to allow a manager to apply a "goodwill credit" refund on top of an already-fully-refunded transaction, which the new code path did not guard against.

dbt build catches a handful of transactions with negative net_amountthe same day the new refund workflow ships. Because this rule — "a refund-adjusted amount must never go negative" — is specific to fct_pos_transactions and doesn't generalize to any other model in the project, it stays a singular test rather than being promoted into a custom generic test; there is nowhere else in the project it would apply.

Samsara — IoT vehicle telemetry, sensor value ranges

Samsara's telemetry pipeline ingests engine temperature readings from vehicle hardware. The team writes a custom generic test, value_within_range, exactly like the one built in Part 05, and applies it to every sensor-reading column across several fact tables — engine temperature, fuel level percentage, tire pressure — each with its own physically sensible min/max bounds.

When a firmware update on one vehicle model starts reporting engine temperature in Fahrenheit instead of the expected Celsius, the value_within_range test on that column fails immediately with values far outside the configured bounds — catching a unit-conversion bug at the data layer within a day of the firmware rollout, rather than an engineer eventually noticing engine temperature dashboards for that vehicle model looked implausibly high weeks later.

// Interview Prep

5 Interview Questions — With Complete Answers

What is the fundamental contract every dbt test follows, and how does that explain what a "failure" actually means?
Every dbt test, generic or singular, is a SQL query expected to return zero rows — that is the entire contract (Part 01). If the query returns any rows at all, the test fails, and each returned row is a concrete example of a record that violated the assertion. This is why dbt test output can show you actual sample failing rows rather than just a pass/fail flag: the "failure" is literally the result set of a SELECT, and dbt is just checking whether that result set is empty.
Explain, mechanically, what the not_null and unique generic tests actually compile down to.
not_null compiles to roughly SELECT * FROM model WHERE column IS NULL — any row returned had a null value in that column. unique compiles to roughly SELECT column FROM model WHERE column IS NOT NULL GROUP BY column HAVING COUNT(*) > 1 — any row returned is a value that appears more than once (Part 03). Neither is a special dbt-internal mechanism; they are ordinary SQL queries generated from a parameterized macro so the same query doesn't have to be retyped for every column and model that needs the same check.
When would you write a singular test instead of a custom generic test, and how do you decide?
The deciding factor is reuse. If a business rule applies to exactly one model and one specific condition — no negative order totals, subscription end dates never preceding start dates — a singular .sql file in tests/ is simpler and needs no YAML wiring at all (Part 04). The moment the same rule, or a parameterized version of it, would apply to more than one column or model, that is the signal to promote it into a custom generic test macro instead (Part 05), so the underlying SQL is written once and reused via YAML rather than copy-pasted across multiple singular test files.
What is the practical difference between running dbt run followed by dbt test, versus running dbt build?
dbt run followed by dbt test as two separate steps builds every model first — including downstream ones — and only checks for test failures afterward, so a downstream model can already be built on top of upstream data that a test later reveals was broken. dbt build interleaves building and testing in dependency order: it builds a model, tests it immediately, and only proceeds to build models that depend on it if those tests passed (Part 06). This is why dbt build, not dbt run, is the standard choice for production scheduled jobs and CI — it stops bad data from propagating further down the DAG within the same run, rather than just reporting the failure after the fact.
Why would the same unique test on a primary key exist at both the staging layer and the mart layer, rather than just once at the mart level where dashboards actually query the data?
The same test at two different layers catches two different failure modes and localizes exactly where a problem was introduced (Part 07). A uniqueness failure at the staging layer means the raw source data itself already contains duplicates. A uniqueness failure at the mart layer, when staging passed cleanly, means a join or aggregation inside the transformation logic between staging and the mart fanned out and created duplicates that did not exist in the source — a completely different class of bug requiring a different fix, in a different place, and testing only at the mart layer would conflate the two.
// Common Mistakes

Five Mistakes Engineers Make Writing Their First dbt Tests

Using unique without not_null on a primary key
A column that is entirely null can pass a unique test on most warehouses, since null is not typically treated as a duplicate of another null. A primary key needs both tests together to actually guarantee "every row has a real, distinct identifier."
Copy-pasting the same singular test SQL for multiple columns or models instead of writing a custom generic test
The moment a business rule needs to apply to more than one column or model, maintaining several nearly-identical singular test files becomes a maintenance burden — a custom generic test macro (Part 05) expresses the same rule once and reuses it declaratively through YAML.
Running dbt run and dbt test as separate scheduled jobs instead of dbt build
This ordering lets every downstream model build on top of upstream data before that data's own tests have even run, defeating the stop-on-failure guarantee that makes testing valuable in production pipelines in the first place.
Only testing at the mart layer, never at staging or the source
A test failure only at the mart layer can't distinguish "the raw source data was already broken" from "the transformation logic introduced the problem" — testing at both layers is what actually localizes the bug's origin.
Treating a passing dbt test suite as proof the data has no problems at all
A model with no tests configured trivially passes an empty test suite. Tests only catch violations of rules someone actually wrote down — a clean dbt test run means the encoded assumptions held, not that every possible data quality issue was checked for.
// Error Library

dbt Testing Errors — And Exactly Why They Happen

Got 3 results, configured to fail if != 0 — relationships_fct_orders_customer_id__customer_id__ref_dim_customers_
Cause: Three customer_id values in fct_orders have no matching row in dim_customers — either dim_customers is missing recently added customers because it ran before fct_orders in the DAG, or fct_orders references a customer_id that was deleted or never existed in the source.
Fix: Check the run order first (dbt build respects DAG dependencies automatically, but a manually staged dbt run of just one model can build it out of order), then check the source data directly for the specific customer_id values dbt reports as failing.
Compilation Error: Test 'positive_value' is not defined
Cause: A custom generic test macro was referenced in schema.yml YAML before it was actually created in tests/generic/, or the file exists but the {% test name(...) %} block's name doesn't exactly match what YAML references.
Fix: Confirm the file exists at tests/generic/test_positive_value.sql (or wherever your project's generic test directory is configured) and that the {% test positive_value(model, column_name) %} declaration's name matches the YAML reference exactly.
A singular test file in tests/ never runs, even though dbt test reports other tests passing
Cause: The file is not a valid standalone SELECT statement — for example it has a trailing semicolon, is missing the ref()/source() Jinja needed to resolve the model, or is not saved with a .sql extension.
Fix: Confirm the file compiles as valid SQL on its own (try dbt compile and inspect the compiled output in target/compiled), uses {{ ref('model_name') }} rather than a hardcoded table name, and ends in .sql.
accepted_values test fails immediately after a routine deploy, with no code changes to the model itself
Cause: An upstream source system started emitting a new categorical value that was never added to the accepted_values values: list — a common occurrence when another team adds a new order status or subscription tier without coordinating with the analytics team.
Fix: Confirm with the upstream team whether the new value is a legitimate addition; if so, add it to the values: list. If not, it may indicate a genuine upstream data quality bug worth flagging back to that team.
dbt build stops partway through, and several models never get built even though their own tests were never reached
Cause: This is dbt build working as designed, not a bug: an upstream model's test failed, and every model that depends on it (directly or transitively through ref()) is correctly skipped rather than being built on top of data already shown to violate an assertion.
Fix: Fix the root failing test's underlying cause first — do not attempt to re-run only the downstream models in isolation until the upstream test passes again, or you will be knowingly building on top of unvalidated data.

🎯 Key Takeaways

  • Every dbt test — generic or singular — follows the same contract: a SQL query expected to return zero rows, where any returned row is a concrete example of a failing record.
  • The four built-in generic tests cover most structural checks: unique, not_null, accepted_values (with a values: list), and relationships (a foreign-key-style check with to: and field:).
  • not_null is literally SELECT * FROM model WHERE column IS NULL under the hood — understanding this compilation demystifies every generic test as ordinary SQL wrapped in a reusable macro.
  • Singular tests are one-off .sql files in tests/ for business rules that don't generalize across models; custom generic tests are macros in tests/generic/ following the test_<name> convention, for rules that do generalize.
  • dbt test runs the whole suite (or --select model_name for one model); dbt build builds and tests every model in dependency order, stopping downstream builds when an upstream model's tests fail — the reason it is the standard choice for production and CI.
  • Source and staging tests catch bad raw data early; mart-level tests catch bugs introduced by the transformation logic itself — the same test at both layers localizes exactly where a problem originated.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...