How to Wire dbt Data Quality Tests Across a Lineage

One not_null test on the final mart lets bad data reach reporting before anyone notices. The fix is to layer dbt data quality tests across the whole lineage - not_null and accepted_values on sources, unique and relationships on staging, and a singular SQL test on the fact - so dbt build fails the moment an assumption breaks. Done right, dbt build exits 0 with at least 8 passing tests.

Data Engineerdbtdata-qualitytests

Why one test on the mart is not enough

A common dbt project has a single not_null test on the final mart and nothing upstream. When a source system starts emitting a bad status value or a broken foreign key, that bad data flows through staging and into the fact table. The lone mart test rarely catches it, so the dashboard is wrong for days before anyone notices.

The fix is to test at every layer of the DAG. dbt runs tests in dependency order, so a source-level failure stops the run before the bad rows ever reach staging. dbt build runs models and tests together, so one command validates the whole lineage.

Step 1 - test the sources

Sources are where bad data enters. Declare columns and generic tests in models/sources.yml so a regression upstream fails fast:

version: 2
sources:
  - name: raw
    schema: public
    tables:
      - name: order_lines
        columns:
          - name: customer_id
            tests: [not_null]
          - name: product_id
            tests: [not_null]
          - name: status
            tests:
              - accepted_values:
                  values: ['pending', 'paid', 'refunded']

accepted_values is the one to reach for on any status or enum column - it catches a new, unexpected value the instant it appears in the source.

Step 2 - test staging keys and joins

Staging models are where you enforce primary-key integrity and foreign-key joins. Add a new models/staging/schema.yml with unique, not_null, and relationships tests:

version: 2
models:
  - name: stg_orders
    columns:
      - name: order_id
        tests: [not_null, unique]
      - name: customer_id
        tests:
          - not_null
          - relationships:
              to: ref('stg_customers')
              field: customer_id
      - name: product_id
        tests:
          - not_null
          - relationships:
              to: ref('stg_products')
              field: product_id
  - name: stg_customers
    columns:
      - name: customer_id
        tests: [not_null, unique]
  - name: stg_products
    columns:
      - name: product_id
        tests: [not_null, unique]

The relationships test is the one that catches a broken join before it silently drops rows in the fact table. to: takes a ref('...') (a model) or a source('...'), and field: is the column to compare against.

Step 3 - a singular test for the business rule

Generic tests cover shape and integrity, but not business invariants. A singular test is just a .sql file in tests/ that SELECTs the rows that violate a rule. dbt's contract: zero rows returned means the test passes.

Create tests/assert_net_amount_consistent.sql to enforce that net_amount = gross_amount - discount_amount:

SELECT
    order_id,
    gross_amount,
    discount_amount,
    net_amount
FROM {{ ref('fct_orders') }}
WHERE abs(net_amount - (gross_amount - discount_amount)) > 0.01

The abs(...) > 0.01 tolerance avoids false failures from floating-point rounding on numeric math.

Run every test in one command

cd dbt_project
dbt build --profiles-dir ..     # runs models AND tests in dependency order
dbt test --profiles-dir ..      # only the tests

A clean run reports PASS=8 or more. Scope work while iterating with --select, and use --store-failures to inspect which rows a failing test returned. Once this is wired, gate merges on dbt build in CI so bad data fails the pull request, not the dashboard.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

What are dbt data quality tests?

dbt data quality tests are assertions you declare in YAML or SQL that dbt runs against your models and sources. Generic tests like not_null, unique, accepted_values, and relationships cover common cases, while a singular test is a .sql file that returns the rows violating a custom rule. dbt build runs them all in dependency order.

How do I test relationships between dbt models?

Use the relationships generic test in a schema.yml column block. Set to to a ref('other_model') or source('...') and field to the column to compare against - for example a customer_id on stg_orders pointing to stg_customers.customer_id. It fails if any value has no matching parent row, catching broken foreign-key joins.

What is a singular test in dbt?

A singular test is a plain .sql file placed in the tests/ directory that SELECTs rows which break a rule. dbt's contract is that zero rows returned means the test passes and any returned rows are failures. Use it for business invariants that generic tests cannot express, like net_amount = gross_amount - discount_amount.

How do I run all dbt tests at once?

Run dbt build to execute models and tests together in dependency order, or dbt test to run only the tests. Add --store-failures to keep the failing rows in a table for inspection, and --select to scope to a subset while iterating.

Keep learning

Repair a dbt Model That Doubles RowsData projectMonitor Data Quality in a PipelineData projectValidate a DataFrame With PanderaData projectData roadmapStep by step to hiredData interview questionsSTAR answersAll Data projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →