Your dbt Tests Pass in CI. They Fail in Production. Here is Why.

· 8 min read

Your dbt Tests Pass in CI. They Fail in Production. Here is Why.

Your dbt Developer Agent just generated 12 new models, 30 tests, and a staging pipeline. CI is green. Then production loads at 3 AM with 12 million NULLs your AI-written not_null test never caught — because the agent tested against seed data, not production patterns.

72% of data teams now prioritize AI-assisted coding. Only 24% prioritize AI-assisted testing and observability. That gap is where trust breaks.

This is not a test quality problem. It is a runtime data problem. CI tests — whether human-written or AI-generated — run against seed data: curated, small, clean. Production runs against whatever the source systems decided to send at 2 AM after a deployment went sideways. The blind spot is identical regardless of who wrote the test.

Here is how to structure dbt testing for production behavior, not just CI correctness.

The Gap: CI Data vs Production Data

CI tests run against seed CSVs — small, clean, static. Production runs against whatever source systems send at 2 AM — millions of rows with unexpected NULLs, unknown enum values, and mid-run schema changes. Passing CI tests give a false sense of security. You need three layers of production-aware testing.

AI Amplifies the Problem

AI agents write more dbt code per sprint than a human team did in a quarter. They also generate more tests — not_null, unique, accepted_values — but these are the same generic assertions that miss production issues. AI accelerates the volume of tests without changing the detection model.

The 2026 State of Analytics Engineering report confirms this: 71% of data professionals are now concerned about incorrect or hallucinated outputs reaching stakeholders. Yet only 24% prioritize AI-assisted pipeline management. Teams are shipping faster and trusting less.

The Fivetran + dbt Labs merger completed last week — a billion-dollar bet that data infrastructure must be agent-ready. But agents are only as good as the validation layer beneath them. More generated code without production-aware testing just accelerates the failure rate.

The fix is not slower code generation. It is production-aware validation that works regardless of whether the test was written by a human or an agent.

Layer 1: Singular Tests for Business Logic (Not Generic Assertions)

Generic tests (not_null, unique, accepted_values) are table stakes. They tell you something is wrong but not what is wrong. Since dbt 1.8, native unit tests let you validate transformation logic with static inputs before data reaches production. But they share the same blind spot as CI — clean data in, green test out.

Singular tests solve the production-facing half of the problem. They encode actual business rules that run against live warehouse data:

-- tests/assert_order_total_matches_line_items.sql
-- Every order total must equal the sum of its line items
WITH order_totals AS (
    SELECT
        order_id,
        SUM(quantity * unit_price) AS calculated_total
    FROM {{ ref('stg_order_items') }}
    GROUP BY order_id
)
SELECT
    o.order_id,
    o.total_amount,
    ot.calculated_total,
    ABS(o.total_amount - ot.calculated_total) AS discrepancy
FROM {{ ref('stg_orders') }} o
JOIN order_totals ot USING (order_id)
WHERE ABS(o.total_amount - ot.calculated_total) > 0.01

Seed data never produces this discrepancy. Production data — with rounding errors, discount timing, or partial refunds — will.

Run singular tests in production DAGs (not just CI) with severity=warn. Let the data land; flag outliers for investigation. Blocking production for non-critical failures creates noise that gets ignored.

Layer 2: Source Freshness as a Production Sensor

dbt source freshness is well known. What teams miss: they run it in CI where all sources are “fresh” because the dev data is loaded moments ago. It only adds value when run in production against actual source tables:

# sources.yml
sources:
  - name: stripe
    database: raw_production
    schema: stripe
    freshness:
      warn_after:
        count: 6
        period: hour
      error_after:
        count: 12
        period: hour
    loaded_at_field: _batched_at
    tables:
      - name: charges
      - name: refunds

Then embed it as a sensor in your orchestration DAG. Parse the target/sources.json output and route stale-source alerts to PagerDuty.

What this catches: Source ingestion pipelines that silently stop. The most expensive data quality bug is the one you do not know exists because the data simply stopped arriving.

Layer 3: dbt Contracts + Custom Generic Tests

dbt’s contract: enforced is the best structural guardrail dbt has shipped. But contracts only check types and nullability. They do not check semantic correctness — what the values mean:

# models/staging/stg_payments.yml
models:
  - name: stg_payments
    config:
      contract:
        enforced: true
    columns:
      - name: payment_id
        data_type: varchar(36)
        constraints:
          - type: not_null
      - name: status
        data_type: varchar(20)
        constraints:
          - type: not_null
      - name: amount
        data_type: numeric(12,2)
        constraints:
          - type: not_null

This catches a column rename. It catches a type change. It does not catch a status column that starts containing "pending_fraud_review" — a perfectly valid varchar that will break your downstream payment reconciliation.

For that, write custom generic tests:

-- tests/generic/test_valid_payment_statuses.sql
{% test valid_payment_statuses(model, column_name) %}

WITH valid_statuses AS (
    SELECT DISTINCT {{ column_name }} AS status
    FROM {{ model }}
)
SELECT status
FROM valid_statuses
WHERE status NOT IN (
    'completed', 'failed', 'refunded', 'pending', 'canceled'
)

{% endtest %}

Apply it alongside the contract at the column level. One catches the pipe breaking; the other catches the water being poisoned.

Measurable Outcome

A team running this three-layer stack at a mid-market fintech reduced production data quality incidents from 14 per quarter to 3. Mean detection time dropped from 6+ hours (someone noticing a dashboard looked wrong) to under 15 minutes (freshness sensor pages before anyone opens a report). Singular tests caught three business-rule violations in the first month that had been silently corrupting revenue reports for over a year.

The metric that matters: time from data arrival to alert. With freshness sensors, that drops from hours to minutes. With singular tests, semantic violations get flagged before they reach the exec dashboard.

Orchestration: Separating CI from Production Testing

Do not run the same test suite in CI and production. Split by tag and severity:

  • CI: dbt test --exclude tag:production_only — fast, core logic
  • Production: dbt test --select tag:production_safe — non-blocking, severity=warn
tests:
  +severity: error
  tag:production_safe:
    +severity: warn

This prevents an unexpected NULL in a low-priority column from blocking the entire production run.

Trade-offs

LayerCostWhen It Does Not Apply
Singular tests~1-5 min per test on large tables; must be maintained per business ruleRapidly changing business logic (tests break faster than they validate)
Source freshness sensorRequires loaded_at_field on source tables; can false-alarm during maintenanceSources that are legitimately infrequent (daily batch uploads)
Contracts + generic testsSchema changes require contract updates; slows model iteration (PR + review + deploy)Prototype / POC models where schema changes hourly
Separate CI/prod test suitesTwo paths to maintain; risk of drift between environmentsSingle-table, low-complexity dbt projects
Non-blocking warningsWarnings are easily ignored; alert fatigue sets inCompliance-critical pipelines (every violation must be blocked)

Decision Framework

  1. Is this model consumed by a dashboard or downstream team?

    • Yes → Add contracts + singular tests for critical columns. Block structural violations, warn on semantic drift.
    • No (internal staging model) → Source freshness + generic tests only. Do not over-invest in strict contracts for transient models.
  2. What happens when bad data reaches this table?

    • Causes incorrect revenue reports → Non-blocking tests with PagerDuty on warning, and a re_run workflow to rebuild on fix.
    • Causes compliance violations → Blocking tests with zero tolerance. Every failure pages.
    • Causes no immediate harm → Source freshness only. Spend your testing budget elsewhere.
  3. How often does the source schema change?

    • Weekly → Contracts are high-leverage. Invest in maintaining them.
    • Daily → Prefer generic tests and tight freshness SLAs. Contracts will be a maintenance tax.
    • Infrequently (monthly+) → Contracts + singular tests. The overhead is negligible.

dbt tests are not a checkbox. A green CI pipeline with seed data tells you nothing about production behavior — whether the tests were written by a senior engineer or an AI agent.

AI will generate more dbt code, faster. That is the trajectory. The question is whether your validation infrastructure keeps pace. Run freshness sensors in production. Write singular tests for business rules. Separate your CI suite from your production suite. The tests that catch production bugs are the ones designed to run against production data.

Trust in data rose from 66% to 83% as an organizational priority this year. That is not a coincidence. It is the direct result of acceleration outpacing governance. The teams that close this gap will be the ones AI agents can actually trust.

Keep Building

This post touched on `dbt` — a core part of data engineering. Let's talk about yours.