dbt Generic Tests: unique, not_null, accepted_values, and relationships
dbt ships with four built-in generic tests, and between them they catch a surprisingly large fraction of the data quality bugs that actually happen in production โ duplicated rows, unexpected nulls, values that donโt match an expected set, and foreign keys pointing at records that donโt exist. Understanding exactly what each one checks, and their configuration options, is worth more than most custom validation logic youโd write from scratch.
unique: No Duplicate Values
models: - name: stg_orders columns: - name: order_id tests: - uniqueThis compiles to a query counting how many values appear more than once in that column, and fails if the count is above zero. Itโs the single most common test in any dbt project, because a supposedly unique key that isnโt unique breaks every downstream join that assumes one row per key โ silently multiplying totals through a fan-out join.
not_null: No Missing Values
columns: - name: customer_id tests: - not_nullStraightforward on the surface, but worth applying deliberately rather than everywhere by default. A not_null test on a genuinely optional column (a middle_name field, an end_date for a still-active subscription) will fail constantly on legitimate data โ reserve it for columns that are supposed to always be populated, like primary and foreign keys.
accepted_values: Enum-Like Validation
columns: - name: status tests: - accepted_values: values: ['pending', 'shipped', 'delivered', 'cancelled', 'returned']This catches a specific and common failure: an upstream system introduces a new status value (say, 'partially_refunded') that no downstream model or dashboard knows how to handle. Without this test, that new value flows silently through every aggregation, potentially miscategorized or dropped by a case when that doesnโt account for it. With the test, the very next dbt test run fails loudly, pointing directly at the new value.
accepted_values also supports a quote config for non-string values and a config.where clause to scope the check:
columns: - name: status tests: - accepted_values: values: ['pending', 'shipped', 'delivered'] config: where: "order_date >= '2024-01-01'"This restricts the check to recent data only โ useful when historical data legitimately contains now-deprecated values you donโt want failing the test every time.
relationships: Referential Integrity
columns: - name: customer_id tests: - relationships: to: ref('stg_customers') field: customer_idThis is dbtโs version of a foreign key constraint โ it checks that every customer_id in stg_orders actually exists in stg_customers. Most analytical warehouses donโt enforce foreign keys at the database level for performance reasons, which makes this test one of the only mechanisms actually catching orphaned records: orders referencing a customer that was deleted, or a sync issue that dropped rows from one table but not the other.
Combining Tests on the Same Column
Tests arenโt mutually exclusive โ a primary key column commonly gets both unique and not_null together, since a null value and a duplicate value are both violations of what a primary key promises.
columns: - name: order_id tests: - unique - not_nullTesting Multiple Columns Together
A less commonly known feature: generic tests can apply to a combination of columns, not just one, using the model-level tests block instead of a column-level one.
models: - name: stg_order_line_items tests: - dbt_utils.unique_combination_of_columns: combination_of_columns: - order_id - line_item_numberThis checks that the pair of order_id and line_item_number is unique together, even if neither column is unique on its own โ the correct check for a table where uniqueness only makes sense at the composite-key level. Note this specific macro comes from dbt_utils, covered in dbt_utils Package, rather than being one of dbtโs four core built-ins.
Configuring Test Severity and Scope
Every generic test accepts a config block for severity thresholds (covered fully in dbt Tests) and a where clause to scope the check to a subset of rows:
columns: - name: discount_pct tests: - not_null: config: where: "order_date >= current_date - interval '90 days'" severity: warnThis only checks the last 90 days of data and treats a failure as a warning rather than a hard build stop โ a realistic pattern when historical data has known gaps youโve already decided not to fix retroactively.
Which Test to Reach for First
For a brand-new staging model, a reasonable default coverage that catches the most common real-world bugs:
| Column type | Recommended test(s) |
|---|---|
| Primary key | unique + not_null |
| Foreign key | relationships + not_null |
| Status/category/enum-like field | accepted_values |
| Free-text or genuinely optional field | Usually no test needed |
Common Mistakes
Applying not_null to every column indiscriminately. This produces constant test noise on legitimately nullable fields and trains the team to ignore failures, which defeats the entire point of testing.
Forgetting relationships tests on foreign keys. Since warehouses rarely enforce this at the database level, skipping this test means orphaned foreign keys go completely undetected until a join silently drops rows or a report undercounts.
Not scoping accepted_values to avoid noise from legacy data. If historical rows contain now-retired status values, use the where config to scope the check to current data rather than disabling the test entirely.
Summary
| Test | Catches |
|---|---|
unique | Duplicate rows breaking join assumptions |
not_null | Missing values in fields that should always be populated |
accepted_values | Unexpected new values in enum-like columns |
relationships | Orphaned foreign keys, since warehouses rarely enforce this natively |
These four generic tests, applied thoughtfully rather than blanket-applied everywhere, catch the majority of real data quality incidents before they reach a dashboard โ for the cases they canโt catch, Custom Tests covers writing your own validation logic.