In dbt, what do the generic tests unique, not_null, accepted_values and relationships check?
answer
- four of them ship in the box
- declared in YAML, not SQL
- one is about keys in another table
- unique, not_null, accepted_values, relationships
basics
~20 sdbt's four built-in generic tests assert column rules from YAML: unique flags duplicate values, not_null flags missing ones, accepted_values flags values outside a declared list, and relationships flags keys with no matching row in a referenced model.
solid answer
~40 sThey are dbt's four out-of-the-box **generic tests**, declared in a YAML file under a model's `columns:` block rather than written as SQL. `unique` fails if a non-null value in the column appears more than once. `not_null` fails on any NULL. `accepted_values` takes a `values:` list and fails on anything outside it. `relationships` takes `to: ref('other_model')` and `field:` and fails on any value with no matching row there — a referential-integrity check dbt runs as a query, since most warehouses do not enforce foreign keys. Each one compiles to a SELECT that returns the offending rows; zero rows returned means the test passed. They are cheap to add and are the baseline every dbt project is expected to have on primary keys and status columns.
code
yaml · 22 linesmodels:
- name: stg_orders
description: One row per order from the source system
columns:
- name: order_id
tests:
- unique
- not_null
- name: status
tests:
- accepted_values:
values: ['placed', 'shipped', 'returned']
- name: amount
tests:
- accepted_values:
values: [0, 1]
quote: false
- name: customer_id
tests:
- relationships:
to: ref('stg_customers')
field: customer_idgo deeper
Memorise the four names and be able to write the YAML block from scratch, including the nested values: list and the to:/field: pair. Interviewers often ask you to type it out.
Explain what each one compiles to and why NULL handling differs between them — unique skips NULLs, accepted_values silently passes them, not_null is the only one that catches them.
Show judgment about which columns deserve which tests, and about scoping expensive tests with a where config so a nightly build does not rescan years of history for a check that only matters on new rows.
Own the project-wide standard: which assertions are mandatory on every published model, which severity they carry, and how you stop a project drifting into either untested marts or thousands of low-value tests nobody reads.
## What a dbt test actually is A dbt data test is a SELECT statement that returns the rows you do **not** want to exist. dbt runs that query against the warehouse: zero rows returned means pass, one or more rows means fail. A *generic* test is that query wrapped in a parameterised Jinja macro, so the same logic can be pointed at any model and any column from YAML instead of being rewritten by hand. dbt ships four generic tests in the core package: `unique`, `not_null`, `accepted_values` and `relationships`. ## Where they are declared They live in a YAML file inside your `models/` directory (conventionally `schema.yml`, but the filename is free). Nesting decides the target: a test under a column applies to that column, a test under the model applies to the model as a whole. ```yaml models: - name: stg_orders columns: - name: order_id tests: - unique - not_null - name: status tests: - accepted_values: values: ['placed', 'shipped', 'returned'] - name: customer_id tests: - relationships: to: ref('stg_customers') field: customer_id ``` dbt 1.8 introduced unit tests and added `data_tests:` as the preferred key for these data tests; `tests:` still works, so you will see both spellings in real projects. ## unique Fails when a value appears in the column more than once. The compiled SQL groups by the column and keeps groups with `count(*) > 1`. NULLs are excluded from the comparison — a column full of NULLs passes `unique`, which is exactly why `unique` and `not_null` are almost always declared as a pair on a primary key. ## not_null The simplest of the four: it selects rows `where <column> is null`. Note that it tests for SQL NULL only. Empty strings, the literal text `'NULL'`, and placeholder values like `-1` all pass it happily; catching those needs `accepted_values` or a custom test. ## accepted_values Takes a `values:` list and fails on any value outside it. By default the values are quoted as strings; set `quote: false` for numeric or boolean columns so the generated SQL compares them without quotes. The gotcha: the compiled predicate is a `not in (...)` filter, and in SQL `NULL not in (...)` is NULL rather than true, so NULL rows are **not** returned and the test passes on them. If NULL is unacceptable in that column, add `not_null` alongside it. ## relationships The referential-integrity check. `to:` names the parent relation — normally `ref('some_model')` or `source('raw', 'some_table')` — and `field:` names the parent column. The compiled query left-joins the child column to the parent column and returns child rows whose parent match is missing, ignoring NULL child values. This matters because cloud warehouses typically declare foreign keys as unenforced metadata at best, so the only thing actually verifying that every `customer_id` on an order exists is this test running after the load. ## What these tests are not They are not warehouse constraints. Nothing is enforced at write time; the model is built first, and the test is a separate query that runs afterwards and reports. A failing test does not delete, quarantine or roll back rows — the bad data is sitting in the table you just published. The consequence you actually get is a non-zero exit code and, if you used `dbt build`, downstream models being skipped. ## Configs worth knowing Every test accepts a `config:` block. `where:` injects a filter so the test only inspects a slice (useful on huge tables: check the last seven days rather than five years). `limit:` caps how many failing rows come back. `severity: warn` downgrades a failure to a warning. `store_failures: true` writes the failing rows to a table so you can inspect them. ```yaml - name: order_id tests: - unique: config: where: "order_date >= current_date - 7" ``` ## How you run them `dbt test` runs every test in the project; `dbt test --select stg_orders` runs the ones attached to that model; `dbt build` interleaves each model with its own tests in DAG order. Beyond these four, the `dbt_utils` package adds widely used ones such as `unique_combination_of_columns` and `expression_is_true`, and you can author your own generic tests when the built-ins do not fit.
- Why are unique and not_null almost always declared together on a primary key?Because dbt's `unique` test excludes NULLs before grouping — a column of a million NULLs has no duplicate values and passes. Only `not_null` catches missing keys. Declaring both is the standard primary-key assertion in a dbt project, since the warehouse itself usually enforces neither.
- A status column has NULLs and an accepted_values test that passes — how?`accepted_values` compiles to a `not in (...)` predicate over the distinct values. In SQL, `NULL not in ('a','b')` evaluates to NULL, not true, so NULL rows are never returned and the test reports a pass. Add `not_null` on the same column if NULL is unacceptable.
- How do you keep a test on a five-year table from scanning everything each run?Put a `where` config on the test: `config: {where: "order_date >= current_date - 7"}`. dbt injects the filter into the test query, so it inspects only the recent slice. It is a genuine trade-off — you stop detecting corruption in older partitions, so many teams pair a windowed test with a rarer full-history run.
Think of them as a pre-flight checklist read after takeoff: the plane is already in the air, but you find out immediately if something is wrong.
saying these in an interview costs you the question
- Says dbt tests create constraints or indexes in the warehouse
- Thinks a failing test prevents bad rows from being written
- Claims relationships is enforced by a real foreign key
- Believes not_null also catches empty strings or 'N/A'
- Says tests are written as SQL files for these four cases