Overview
dbt distinguishes data tests (assertions on rows, e.g. unique / not_null) from unit tests (logic tests on transformations); this skill targets data tests. The YAML key was renamed from tests: to data_tests: to disambiguate; tests: remains a back-compat alias but you cannot have both on the same resource at the same time (data-tests (opens in new window)).
When to use
Authoring tests
Generic tests in schema.yml
Use data_tests: (canonical) - tests: is the legacy alias and cannot coexist with data_tests: on the same resource (data-tests (opens in new window)).
version: 2
models:
- name: orders
columns:
- name: order_id
data_tests:
- unique
- not_null
- name: status
data_tests:
- accepted_values:
arguments:
values: ['placed', 'shipped', 'completed', 'returned']
- name: customer_id
data_tests:
- relationships:
arguments:
to: ref('customers')
field: idThe arguments: key under parameterized generics (accepted_values, relationships) is the canonical syntax in current dbt (data-tests (opens in new window)).
Singular tests
Singular tests live as .sql files under tests/ (or a custom test-paths config). Each file is one select that returns failing rows - empty result set means pass (data-tests (opens in new window)).
tests/assert_payment_total_non_negative.sql:
-- Returns rows where the aggregated payment total is negative;
-- empty result set = test passes.
select order_id, sum(amount) as total_amount
from {{ ref('fct_payments') }}
group by 1
having sum(amount) < 0Trap: dbt requires you to omit trailing semicolons in singular test SQL - a
;at the end can cause the test to fail spuriously (data-tests (opens in new window)). This is unlike most SQL editors which add the semicolon by default.
Custom generic tests via macros
Reusable assertions are defined as Jinja {% test %} blocks that take model and column arguments and return a select of failing rows (data-tests (opens in new window)):
{% test column_value_in_range(model, column_name, min_value, max_value) %}
select *
from {{ model }}
where {{ column_name }} < {{ min_value }}
or {{ column_name }} > {{ max_value }}
{% endtest %}Reference once defined, then call by name in data_tests:. See references/macros.md for the canonical macro shapes (range checks, conditional uniqueness, recency / freshness, external referential integrity).
Running
dbt test runs data tests defined on models, sources, snapshots, seeds, plus unit tests on SQL models (dbt-test (opens in new window)).
# Run every data test in the project
dbt test --select test_type:data
# Run every test (data + unit) on a specific model
dbt test --select orders
# Run only singular tests
dbt test --select test_type:singular
# Run only generic tests
dbt test --select test_type:generic
# Combine selectors with comma (intersection)
dbt test --select "orders,test_type:data"
# Run tests for everything in a package
dbt test --select "my_package.*"dbt build is the integrated command that runs models, tests, snapshots, seeds, and user-defined functions in DAG order. Critically, a failing upstream test causes downstream resources to skip - so dbt build is the right command for a pipeline that should not propagate bad data (dbt-build (opens in new window)):
dbt build # full project, DAG order
dbt build --select state:modified+ # changed nodes + descendants
dbt build --empty # zero-row dry run for validation
dbt build --select "test_type:unit" # unit-test-only build subsetSeverity can be relaxed from error to warn on a per-test basis to prevent a single test from cascading skips downstream (dbt-build (opens in new window)).
Parsing failures
Both dbt test and dbt build produce run_results.json in the dbt artifacts directory. Each result entry is a node-level record with (run-results-json (opens in new window)):
| Field | Meaning |
|---|---|
unique_id | Node identifier (e.g. test.<project>.<test_name>); maps to manifest.json. |
status | dbt's interpretation of runtime success / failure / error. |
execution_time | Total node execution duration (seconds). |
timing | Per-phase breakdown (compile, execute). |
message | Human-readable CLI message based on adapter output. |
failures | Count of test failures (rows returned by the test SELECT). |
adapter_response | Database-returned metadata (varies by warehouse adapter). |
compiled | Whether the node compiled successfully. |
compiled_code | The rendered SQL string (useful for debugging). |
relation_name | Fully-qualified database object name. |
A failing data test typically appears as status: "fail" with failures: <N> (rows returned). A test that errored before producing rows (e.g. a SQL compilation error) appears differently - handle both when parsing.
Quick triage with jq:
# List all failing test nodes
jq -r '.results[] | select(.status=="fail") | .unique_id + " (" + (.failures|tostring) + " failures)"' \
target/run_results.json
# Surface the first 5 failure messages
jq -r '.results[] | select(.status=="fail") | .message' target/run_results.json | head -5CI integration
Use dbt build (not dbt test alone) so the pipeline halts at the first failed assertion in the DAG and skips downstream nodes. Always upload target/run_results.json (and target/manifest.json) as an artifact so the failing-test list survives the run.
See references/ci-integration.md for ready-to-paste workflow snippets for GitHub Actions, GitLab CI, and Jenkins, plus the --store-failures and slim-CI patterns that scope tests to changed nodes.
References
Patterns for running dbt build (preferred over standalone dbt test) in CI, with artifact upload and slim-CI scoping. All snippets use dbt build because of its DAG-aware skip-on-failure behavior - a failing upstream test causes downstream resources to skip, which is what you want in a quality gate (dbt-build (opens in new window)).
GitHub Actions
name: dbt build
on:
pull_request:
push:
branches: [main]
jobs:
dbt-build:
runs-on: ubuntu-latest
env:
DBT_PROFILES_DIR: .ci/dbt
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # needed for state:modified+ slim CI
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dbt + adapter
run: pip install 'dbt-core>=1.8' 'dbt-postgres'
- name: dbt deps
run: dbt deps
# Slim CI: only build/test changed nodes + their descendants.
# Requires manifest.json from a previous run (e.g. main) at .dbt-state/.
- name: dbt build (slim)
run: |
if [ -f .dbt-state/manifest.json ]; then
dbt build --select state:modified+ --state .dbt-state/
else
dbt build
fi
- name: Upload run_results.json
if: always()
uses: actions/upload-artifact@v4
with:
name: dbt-run-results
path: |
target/run_results.json
target/manifest.json
retention-days: 14The if: always() on the artifact upload is important - without it, run_results.json is lost on failure, which is exactly when you need it for triage.
GitLab CI
dbt-build:
image: python:3.12
variables:
DBT_PROFILES_DIR: $CI_PROJECT_DIR/.ci/dbt
before_script:
- pip install 'dbt-core>=1.8' 'dbt-postgres'
- dbt deps
script:
- dbt build
artifacts:
when: always
paths:
- target/run_results.json
- target/manifest.json
expire_in: 14 daysJenkins (declarative)
pipeline {
agent any
environment {
DBT_PROFILES_DIR = "${WORKSPACE}/.ci/dbt"
}
stages {
stage('Install') {
steps {
sh 'pip install --user "dbt-core>=1.8" "dbt-postgres"'
sh 'dbt deps'
}
}
stage('Build') {
steps {
sh 'dbt build'
}
}
}
post {
always {
archiveArtifacts artifacts: 'target/run_results.json,target/manifest.json',
allowEmptyArchive: true
}
}
}Slim CI: scoping to changed nodes
The state:modified+ selector restricts a build to nodes that changed relative to a baseline manifest.json (downloaded from a previous main run) plus their descendants (dbt-build (opens in new window)):
# 1. On main: produce the baseline manifest and store it (e.g. S3, artifact)
dbt build
mkdir -p .dbt-state
cp target/manifest.json .dbt-state/
# 2. On PR: download .dbt-state/manifest.json, then:
dbt build --select state:modified+ --state .dbt-state/This is the canonical pattern for a fast PR check that doesn't rebuild the entire warehouse.
Empty / dry-run validation
Use --empty (dbt 1.8+) for a zero-row build that compiles every node and checks DAG integrity without expensive data reads (dbt-build (opens in new window)):
dbt build --emptyPair this with the slim-CI pattern when you want a fast dependency-graph check before kicking off the data build.
Storing failures for triage
Add --store-failures to materialize the failing rows of each test into a warehouse audit table:
dbt build --store-failuresThe audit tables let you triage failures with the actual offending rows in hand, instead of only the count from run_results.json.
Parsing run_results.json in CI
A small post-build step that surfaces failing tests in the run summary:
jq -r '
.results[]
| select(.status == "fail")
| "FAIL: \(.unique_id) - \(.failures) failing rows - \(.message)"
' target/run_results.jsonPipe this to tee and to a job summary file ($GITHUB_STEP_SUMMARY on GitHub Actions) for a clickable failure report on the run page.
Patterns for {% test %} blocks beyond the four built-in generics. Each test is a Jinja macro that takes a model (and optional column arguments) and returns a select of failing rows - empty result set means pass (per the canonical data-tests (opens in new window) doc).
All examples use the canonical data_tests: YAML key with arguments: for parameterized generics (data-tests (opens in new window)).
Range check
Asserts a numeric column stays within [min_value, max_value]. Inclusive bounds.
{% test column_value_in_range(model, column_name, min_value, max_value) %}
select *
from {{ model }}
where {{ column_name }} < {{ min_value }}
or {{ column_name }} > {{ max_value }}
{% endtest %}Usage:
- name: discount_percent
data_tests:
- column_value_in_range:
arguments:
min_value: 0
max_value: 100Conditional uniqueness
Built-in unique enforces uniqueness across the entire column. When you need uniqueness only within a subset (e.g. unique email per active account), parameterize the predicate.
{% test conditional_unique(model, column_name, where_clause) %}
select {{ column_name }}, count(*) as n
from {{ model }}
where {{ where_clause }}
group by {{ column_name }}
having count(*) > 1
{% endtest %}Usage:
- name: email
data_tests:
- conditional_unique:
arguments:
where_clause: "status = 'active'"Recency / freshness
For sources, dbt has a built-in freshness block (different mechanism). For models, a custom test pattern is to assert the most recent row is within a target window.
{% test row_recency(model, column_name, max_age_hours) %}
{# fails if max(column_name) is older than max_age_hours #}
select max({{ column_name }}) as latest
from {{ model }}
having max({{ column_name }}) < dateadd(hour, -{{ max_age_hours }}, current_timestamp)
{% endtest %}dateadd syntax varies by warehouse adapter - adjust per Snowflake / BigQuery / Postgres / Redshift dialect. Test the raw SQL in your warehouse console first.
External referential integrity
The built-in relationships test asserts a foreign key against another dbt model via ref(). For an external warehouse table not modeled by dbt, use a parameterized macro:
{% test external_relationship(model, column_name, to_relation, to_column) %}
select {{ column_name }} as orphan
from {{ model }}
where {{ column_name }} is not null
and {{ column_name }} not in (select {{ to_column }} from {{ to_relation }})
{% endtest %}Usage:
- name: external_account_id
data_tests:
- external_relationship:
arguments:
to_relation: 'analytics.dim_account_external'
to_column: 'account_id'Severity and warning thresholds
Any data test (built-in or custom) accepts a config: block that lets you treat the test as a warning instead of a hard failure, or set a fail/warn threshold based on the row count returned:
- name: order_id
data_tests:
- unique:
config:
severity: warn # warn instead of error
warn_if: ">10" # warn if more than 10 dupes
error_if: ">100" # hard fail if more than 100 dupesThis is the canonical mechanism to prevent a single test from blocking downstream models in dbt build - relaxing severity to warn lets the DAG keep flowing while still surfacing the assertion in run_results.json (dbt-build (opens in new window)).
Storing failures
Add --store-failures to dbt test (or dbt build) to persist the failing rows to a warehouse table for triage, instead of only emitting counts. Once enabled, each failing test materializes a small audit table under the configured dbt_test__audit schema.
dbt test --select state:modified+ --store-failuresUse this when the failure count alone is not enough to debug - the audit table contains the actual rows the test SELECT returned.
- Contractor left mid-migration and now the marts schema file will not parse
- The nightly did not stop, and the assertion that should have stopped it says it passed
- The PR check went from 41 minutes to 88 seconds and three regressions walked in behind it
- Six weeks of green nights from three checks I am no longer sure ever ran
- Platform lead wants three assertions taken off the blocking path before tonight