skip to content

Tests and Documentation

Generic tests declared in YAML, singular tests written as SQL, and a docs site generated from the same files. Interviewers ask what you actually test — uniqueness, not-null, referential relationships — because that is how a dbt project stays trustworthy.

on this pageshow

questions

7

In dbt, what do the generic tests unique, not_null, accepted_values and relationships check?

level: juniorimportance: must knowfreq 80%

answer

  1. four of them ship in the box
  2. declared in YAML, not SQL
  3. one is about keys in another table
  4. unique, not_null, accepted_values, relationships

basics

~20 s

dbt'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 s

They 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 lines
yaml
models:
  - 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_id

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

In dbt, what SQL does a test compile into, and what makes it pass or fail?

level: middleimportance: must knowfreq 64%

basics

~20 s

Every dbt test compiles to a SELECT that returns the failing rows, wrapped in a count. Zero rows means pass; any rows returned means fail, and the count is reported as the number of failures.

open as a page

In dbt, what do dbt docs generate and dbt docs serve produce?

level: middleimportance: should knowfreq 45%

basics

~20 s

dbt docs generate writes manifest.json and catalog.json into the target directory — the project graph plus column metadata queried from the warehouse. dbt docs serve starts a local web server rendering those files as a browsable catalog with a lineage graph.

open as a page

In dbt, when would you write a singular test instead of a generic one?

level: middleimportance: should knowfreq 55%

basics

~20 s

Write a dbt singular test when the assertion is one-off and specific to particular models: a plain SQL file in tests/ that selects offending rows. Generic tests are parameterised macros declared in YAML and reused across many models and columns.

open as a page

In dbt, why does dbt build skip downstream models on a test failure when dbt run then dbt test does not?

level: seniorimportance: should knowfreq 52%

basics

~20 s

dbt build walks the DAG node by node, running each model and then its own tests before moving on, so a failed test marks that node an error and its descendants are skipped. dbt run then dbt test builds everything first and only tests afterwards.

open as a page

A dbt not_null test fails on 3 rows and blocks the nightly build — how do you unblock it?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Set the test's severity to warn, or give it a threshold with warn_if and error_if so small counts warn while large ones still fail. Turn on store_failures to persist the offending rows for investigation instead of deleting the test.

open as a page

In dbt, how do you write a custom generic test that can be reused from schema YAML?

level: middleimportance: nice to knowfreq 36%

basics

~20 s

Define a Jinja block with the test tag taking model and column_name arguments, save it under tests/generic or macros, and return the failing rows. Any model's YAML can then declare it by name and pass extra arguments.

open as a page