Data Engineering  /  dbt

๐Ÿ”„ dbt โ€” Data Build Tool 60 guides ยท updated 2026

Analytics engineering with SQL โ€” models, tests, sources, and Jinja macros that turn raw warehouse tables into trustworthy, documented data products.

Custom Tests in dbt: Writing Business-Specific Validation Logic

The four built-in generic tests covered in Generic Tests handle uniqueness, nulls, enums, and referential integrity โ€” but they canโ€™t express โ€œa completed order should never have a negative totalโ€ or โ€œno customer should have more than one active subscription at the same time.โ€ Rules like that are specific to your business logic, and dbt gives you two ways to write them: singular tests for one-off checks, and custom generic tests when the same logic needs to be reused across multiple models.


Singular Tests: The Simplest Custom Check

A singular test is just a SQL file in the tests/ directory that returns rows representing failures. If the query returns zero rows, the test passes. If it returns any rows, the test fails โ€” and shows you exactly which rows violated the rule.

-- tests/assert_no_negative_completed_order_totals.sql
select
order_id,
total_amount
from {{ ref('stg_orders') }}
where status = 'completed'
and total_amount < 0

Run it exactly like any other test:

Terminal window
dbt test --select assert_no_negative_completed_order_totals

Thereโ€™s no special syntax to learn โ€” a singular test is just โ€œwrite a query that returns the rows you donโ€™t want to exist.โ€ This is the fastest path from โ€œI have a business rule in my headโ€ to โ€œthat rule is now enforced automatically on every run.โ€


A More Realistic Singular Test Example

-- tests/assert_single_active_subscription_per_customer.sql
select
customer_id,
count(*) as active_subscription_count
from {{ ref('stg_subscriptions') }}
where status = 'active'
group by customer_id
having count(*) > 1

This catches a genuinely important business invariant โ€” a billing system bug that lets a customer end up with two simultaneously active subscriptions โ€” that none of the four generic tests could express, because it depends on an aggregation and a business-specific rule, not a simple per-row check.


Custom Generic Tests: Reusable Across Models

When the same custom check needs to apply to more than one model or column, a singular test starts requiring copy-paste. A custom generic test, defined as a macro following a naming convention, solves this the same way built-in generic tests work.

-- macros/test_not_negative.sql
{% test not_negative(model, column_name) %}
select *
from {{ model }}
where {{ column_name }} < 0
{% endtest %}

Once defined, itโ€™s usable in YAML exactly like unique or not_null:

models:
- name: stg_orders
columns:
- name: total_amount
tests:
- not_negative
- name: stg_refunds
columns:
- name: refund_amount
tests:
- not_negative

The macro is written once and reused across every column that needs the same check โ€” the same reuse principle covered generally in dbt Macros, applied specifically to test logic.


Custom Generic Tests With Parameters

Generic tests can accept additional arguments beyond model and column_name, letting the same test logic flex across different thresholds.

-- macros/test_value_within_range.sql
{% test value_within_range(model, column_name, min_value, max_value) %}
select *
from {{ model }}
where {{ column_name }} < {{ min_value }}
or {{ column_name }} > {{ max_value }}
{% endtest %}
columns:
- name: discount_pct
tests:
- value_within_range:
min_value: 0
max_value: 100

This single test macro now covers any bounded-range check across the entire project, with the actual bounds specified per-column at the point of use.


Singular Tests With Configurable Severity

Custom tests support the same config options as built-in ones โ€” severity, where scoping โ€” either inline in the SQL file or via a corresponding YAML entry.

-- tests/assert_no_negative_completed_order_totals.sql
{{ config(severity='warn') }}
select order_id, total_amount
from {{ ref('stg_orders') }}
where status = 'completed'
and total_amount < 0

Testing Across Multiple Models in One Singular Test

Singular tests arenโ€™t limited to a single model โ€” a common pattern is validating a relationship or invariant that spans two models a generic testโ€™s model/column_name structure canโ€™t express cleanly.

-- tests/assert_order_totals_match_line_items.sql
with order_totals as (
select order_id, total_amount from {{ ref('stg_orders') }}
),
line_item_sums as (
select order_id, sum(line_amount) as computed_total
from {{ ref('stg_order_line_items') }}
group by order_id
)
select
o.order_id,
o.total_amount,
l.computed_total
from order_totals o
join line_item_sums l using (order_id)
where abs(o.total_amount - l.computed_total) > 0.01

This catches a real and common data integrity problem โ€” an orderโ€™s stated total drifting out of sync with the sum of its line items โ€” that no built-in test could express, since it requires joining two models and comparing a computed aggregate.


When to Reach for a Custom Test vs. a Generic One

SituationApproach
One-off, business-specific check, used onceSingular test
Same custom logic needed across multiple columns/modelsCustom generic test (macro)
Common enough that other teams likely need it tooCheck dbt_utils/dbt_expectations before writing your own

Common Mistakes

Writing a singular test when a custom generic test would prevent duplication. If youโ€™re about to copy a singular test file and rename it for a second model, thatโ€™s the signal to convert it to a parameterized generic test instead.

Forgetting that the query should return failing rows, not a boolean. A common early mistake is writing a query that returns true/false โ€” dbt tests are pass/fail based on row count, not a boolean value, so the query needs to select the actual offending rows.

Not naming custom tests descriptively. tests/test1.sql tells a future reader nothing; assert_no_negative_completed_order_totals.sql documents the business rule in the filename itself.

Summary

ApproachBest for
Singular testQuick, one-off business rule validation
Custom generic test ({% test %} macro)Reusable business logic applied across models
Multi-model singular testCross-model invariants (totals matching, no overlapping records)

Custom tests are where dbtโ€™s testing framework stops being a generic data-quality tool and starts encoding your actual business rules as executable, automatically-checked assertions โ€” the same discipline unit tests bring to application code, applied to data.