dbt CI/CD That Catches Problems Before They Reach Prod
A complete dbt CI/CD pipeline that builds only what changed, isolates writes in a temporary schema, lints first, and blocks bad merges—plus real GitHub Actions YAML.
Your last broken dashboard didn’t fail on your laptop; it failed after a merge. Here’s a dbt CI/CD setup that prevents that: run on every pull request, build only what changed with state:modified plus deferral to the last good run, write into a temporary schema and tear it down, enforce linting, and require green checks before the merge to main. You’ll get working GitHub Actions YAML, selectors that keep runs cheap, and a permission model that won’t let a PR write into production. If you run a dbt Cloud project, the same pattern applies with hosted runners. This is the pipeline in dbt that an analytics engineer can operate without surprises in a real data warehouse.
PR-triggered builds with GitHub Actions (end to end)
This is the lean ci workflow we drop into teams using GitHub. It validates new code on PRs, compiles fast, and leaves no objects behind. The job pulls prior artifacts for deferral, runs pre-commit to guard code quality, and then builds only modified nodes. Projects using GitHub Actions can save this as .github/workflows/dbt-ci.yml and mark its status as a required check. The same structure ports to GitLab or Azure DevOps.
name: dbt PR checks
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
DBT_TARGET: ci
DBT_CI_SCHEMA: CI_${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install
run: |
python -m pip install --upgrade pip
pip install dbt-core dbt-snowflake pre-commit
- name: Profiles
run: |
mkdir -p ~/.dbt
cat > ~/.dbt/profiles.yml << 'YAML'
profile_name:
target: ${DBT_TARGET}
outputs:
ci:
type: snowflake
account: ${{ secrets.SNOWFLAKE_ACCOUNT }}
user: ${{ secrets.SNOWFLAKE_USER }}
password: ${{ secrets.SNOWFLAKE_PASSWORD }}
role: CI_ROLE
database: ANALYTICS
warehouse: CI_XS
schema: ${DBT_CI_SCHEMA}
YAML
- name: Pre-commit
run: pre-commit run --all-files --show-diff-on-failure
- name: Dependencies
run: dbt deps
- name: Pull last artifacts for deferral
if: ${{ secrets.ARTIFACTS_READ_URL != '' }}
run: |
curl -sSL ${{ secrets.ARTIFACTS_READ_URL }} -o artifacts.zip
unzip -o artifacts.zip -d target
- name: Build changed only
run: |
# This ci job builds only modified nodes (+ their dependents)
dbt build --select state:modified+ --defer --state target/ --target ci --fail-fast --threads 4
- name: Always drop schema
if: always()
run: dbt run-operation drop_ci_schema --args '{"schema": "'${DBT_CI_SCHEMA}'"}'
Using GitHub Actions keeps the workflow close to the repository and lets you automate required checks via the GitHub API. If you prefer, mirror it in GitLab CI or run it in Azure DevOps; the graph logic doesn’t change.
State:modified + deferral: build what changed, trust what didn’t
Full builds on every PR are slow and misleading. The right selector is state:modified+: build changed nodes and any affected downstream. Pair it with --defer so references to untouched parents resolve to the last successful run’s artifacts, not whatever’s in a developer’s dev schema.
# Pre-req: after deployment, publish manifest/run_results to object storage
# or as a GitHub release asset, then fetch them in PR checks.
$ dbt build \
--select state:modified+ \
--defer \
--state target/ \
--target ci
What breaks without deferral: a tiny change forces an hour-long rebuild, or CI passes against stale dev tables and fails post-merge. Persist artifacts from your deploy step, store a read-only URL in secrets, and fetch into target/. If you run dbt Cloud, you can set the state to the latest job artifacts. This is the best practice when you want a pipeline for your dbt Core to be both fast and relevant.
Isolation with a temporary schema and a teardown you can’t forget
Never write into shared dev or stage schemas during PR checks. Create an isolated, per-PR temporary schema—simple pattern: CI_<pr-number>—and always drop it in a final step. That prevents collisions between concurrent PRs and keeps production clean. Incremental models should run without --full-refresh: you are validating logic, not backfilling a 40M-row table on an X-Small warehouse.
-- Macro-backed teardown (warehouse-agnostic idea)
{% macro drop_ci_schema(schema) %}
{% if target.adapter == 'snowflake' %}
begin
execute immediate 'drop schema if exists ' || target.database || '.' || '{{ schema }}' || ' cascade';
end;
{% else %}
drop schema if exists {{ target.database }}.{{ schema }} cascade;
{% endif %}
{% endmacro %}
Call it with dbt run-operation in a finalizer so failure doesn’t skip cleanup. Keep seeds small or tag heavy ones out of selection. For more patterns that reduce graph churn and IO, see our dbt repo performance work.
Linting and pre-commit that block bad changes early
Catching issues before you run dbt saves warehouse time. Use pre-commit to enforce SQL style and structural checks locally and in PRs. dbt offers the open-source dbt-checkpoint hooks; pair them with a SQL linter to raise obvious problems—broken Jinja, missing refs, unsafe selectors—before any build. This small gate improves code quality without slowing developers down.
# .pre-commit-config.yaml (minimal, fast)
repos:
- repo: https://github.com/sqlfluff/sqlfluff
rev: 3.0.7
hooks:
- id: sqlfluff-lint
additional_dependencies: ["sqlfluff-templater-dbt"]
- repo: https://github.com/dbt-checkpoint/dbt-checkpoint
rev: v1.2.2
hooks:
- id: dbt-compile
- id: dbt-docs-generate
Install hooks locally with pre-commit install so broken SQL doesn’t make it to PRs. In Actions, run pre-commit run --all-files before any warehouse work. If your project compiles slowly, start with the fixes in dbt run slow? Why it happens and 7 fixes.
dbt build vs run+test (and the failure modes they hide)
You can wire “build and test” as two commands—or use dbt build. The differences matter.
| Aspect | dbt build | run + test |
|---|---|---|
| What executes | Models, seeds, snapshots, tests | Models then tests only |
| Order | Topological node-by-node | All models, then all tests |
| Stops on first fault | Yes | Often no |
| Snapshot coverage | Included | Easy to forget |
| Selectors | First-class | Manual juggling |
Teams who script “run then test” often miss new snapshots in week two: CI stays green, and a downstream exposure fails after merge. Prefer dbt build with state:modified+ and --defer. Include a few critical dbt test checks close to raw data so a column rename or contract break stops the walk at the right node. That makes failures obvious for reviewers in Git, not in production.
Cost controls and permissions that don’t bite later
Left alone, PR checks can outspend your scheduled runs. Practical controls:
- Selection:
state:modified+only; tag out heavy seeds/tests. - Small warehouse and thread cap; cancel in-progress builds on new pushes.
- Query tagging for chargeback; analyze later. See our notes on spend in Snowflake cost optimization.
- Time-box: fail if runtime exceeds your expected ceiling.
Permissions (least privilege) keep a PR from touching production schemas. Example grants (Snowflake-style) for a CI role: usage on database and warehouse, create schema in analytics, read-only on sources, no modify in production schemas.
-- One-time role setup
create role CI_ROLE;
-- Grant basics
grant usage on database ANALYTICS to role CI_ROLE;
grant usage on warehouse CI_XS to role CI_ROLE;
grant create schema on database ANALYTICS to role CI_ROLE;
-- Source access
grant usage on all schemas in database RAW to role CI_ROLE;
grant select on all tables in database RAW to role CI_ROLE;
Store credentials as repository secrets and pass them as environment variables. This keeps automated CI safe while still exercising real permissions in a staging environment. For orchestration lift-and-shift or hosted runners, see our dbt Cloud migration guide.
FAQ: swaps, mini tables, YAML files, and deployment triggers
Run on Pull Request?
Yes. Trigger on every PR update and block the merge until checks pass. That’s continuous integration, not after-the-fact cleanup.
What is CI/CD?
Continuous integration and continuous deployment: build and test on each commit, then deploy from the main branch to the production environment after required checks. Keep PR checks and deployment as separate workflows.
Also, wouldn’t swapping pre-prod into production be better than rerunning?
Table swaps are fast but brittle: schema drift, grants, and late-binding views break. If you swap, do it inside production only (e.g., write to a prod-pre schema, validate, then swap), and still deploy dbt to rebuild models whose parents changed.
Or just create a mini table with the new data?
Use samples in PR checks, but don’t copy partials into production. Validate incremental logic with selectors; for real backfills, see our incremental strategies that hold up in prod.
Did you know dbt-checkpoint provides recommended pre-commit hooks?
Use them. They catch broken refs and docs before builds. We showed a minimal config above.
Manual review or automation?
Both. Require human review plus green checks. You can automate deploy to stage on merge to main; promotion to production can stay manual or become gated continuous deployment after burn-in.
If we have a staging.yml, why add a deploy workflow?
A YAML file defines models; a deploy workflow handles release. Keep PR checks on pull_request and a separate deploy job on push to the main branch with full selection. That ci process scales across GitHub Actions, GitLab, or Azure DevOps.
What should your pipeline look like?
In order: pre-commit checks, compile, fetch prior artifacts, dbt build with deferral in an isolated schema, teardown, and a required status on the PR. That’s a dependable ci pipeline that an analytics engineer or data engineering on-call can reason about. It’s how the ci job builds confidence in new code without blowing up spend.
This is what we implement for clients and run ourselves. If you want a fast review of selectors, artifacts, and deployment gates, start with our dbt articles or contact Vertex. We’ll automate what matters and cut the noise.
About the author
Eric Provencio — Analytics engineer who has built and run production data platforms for Disney, Hulu, Nike, Peloton, Gopuff, and Kaplan. Founded Vertex Data Consulting to do the deep work most data teams never find time for: dbt Cloud migrations, repo performance, Airflow reliability, and AI agents that actually touch the stack.