CI/CD for Data Pipelines
Testing dbt models in CI, environment promotion, blue-green deployments, Airflow deployment patterns, slim CI, and building a safe deployment pipeline for data transformations.
CI/CD for Data — Why Deploying a dbt Model Is Not Like Pushing Code
Software CI/CD is well-understood: commit code, run unit tests, deploy to staging, run integration tests, deploy to production. Data pipeline CI/CD shares this structure but has unique challenges. A dbt model change does not just change code — it changes the data in a production table that analysts are querying right now.
A software bug surfaces as an error page users see and report. A data bug surfaces as a wrong number that looks correct until someone notices it doesn’t match expectations — often days later. This module builds FreshCart’s dbt and Airflow CI/CD pipeline around that asymmetry.
Environment Strategy — Dev, Staging, and Production
A data platform needs at least dev and production, and ideally a staging/CI environment that mirrors production data. Each environment serves a specific purpose, and configuration must ensure code flows one direction: dev → staging → prod.
DEV — individual developer sandbox
Data: subset of production (last 7 days). Schema: dev_{developer_name}
Isolation: complete — dev changes cannot affect staging or prod
Lifespan: created on branch checkout, deleted after merge
STAGING / CI — automated testing environment
Data: clone of production (Zero-Copy Clone). Schema: ci_{PR_number}
Isolation: each PR gets its own schema
Lifespan: created on PR open, deleted after PR merge
PRODUCTION — serves real analysts and BI tools
Data: full production data, updated by live pipelines
Access: pipeline service accounts write; analysts read-only
Lifespan: permanentdbt profiles.yml — one file, three targets
freshcart:
target: dev
outputs:
dev:
type: snowflake
account: freshcart.snowflake.com
user: "{{ env_var('SNOWFLAKE_USER') }}"
role: analyst_role
database: freshcart_dev
schema: "dev_{{ env_var('DBT_DEV_SCHEMA', 'default') }}"
ci:
type: snowflake
role: ci_service_role
database: freshcart_ci
schema: "ci_{{ env_var('PR_NUMBER', 'manual') }}" # ci_142, ci_143, ...
prod:
type: snowflake
role: pipeline_role
database: freshcart_prod
schema: silver # or gold, depending on the model groupSnowflake Zero-Copy Clone — production-like staging at near-zero cost
Cloning 10 TB of production data for every PR would be expensive and slow. Snowflake’s Zero-Copy Clone creates an instant snapshot that shares data pages with the source until rows are modified.
def create_ci_environment(pr_number: int) -> str:
ci_db = f'freshcart_ci_pr_{pr_number}'
snowflake_conn.execute(f"""
CREATE OR REPLACE DATABASE {ci_db}
CLONE freshcart_prod
DATA_RETENTION_TIME_IN_DAYS = 1
""")
return ci_db
def teardown_ci_environment(pr_number: int) -> None:
snowflake_conn.execute(f'DROP DATABASE IF EXISTS freshcart_ci_pr_{pr_number}')
# In the CI pipeline: create on PR open → dbt test --target ci → teardown on PR close$ python -c "from scripts.ci import create_ci_environment; create_ci_environment(142)"
Creating database freshcart_ci_pr_142 as a clone of freshcart_prod...
Done in 2.1s. Storage cost: $0.00 (shared pages with freshcart_prod)dbt CI — What to Run on Every Pull Request
A dbt CI pipeline runs on every pull request before merge. The key challenge is speed — a 45-minute CI run tempts developers to merge without waiting. The answer is slim CI: only test models that were changed, or depend on changed models.
The workflow shell — trigger, environment, and setup
name: dbt CI
on:
pull_request:
branches: [main]
paths: ['dbt/**']
jobs:
dbt-ci:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CI_SNOWFLAKE_USER: ${{ secrets.CI_SNOWFLAKE_USER }}
CI_SNOWFLAKE_PASSWORD: ${{ secrets.CI_SNOWFLAKE_PASSWORD }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # needed for dbt --select state:modified
- uses: actions/setup-python@v5
with: { python-version: '3.11', cache: pip }
- run: pip install dbt-snowflake==1.8.0 dbt-utils
- name: Create CI database (Zero-Copy Clone)
run: python scripts/ci/create_ci_db.py --pr ${{ github.event.pull_request.number }}
- run: dbt deps
working-directory: dbtThe checks that actually catch problems
- name: dbt compile (catch SQL syntax errors)
working-directory: dbt
run: dbt compile --target ci
- name: dbt run — SLIM CI (only changed models + downstream)
working-directory: dbt
run: dbt run --target ci --select state:modified+ --defer --state ./prod_artifacts
- name: dbt test — tests for changed models + downstream
working-directory: dbt
run: dbt test --target ci --select state:modified+ --defer --state ./prod_artifacts --store-failures
- name: Check for breaking schema changes
run: python scripts/ci/check_schema_changes.py --pr ${{ github.event.pull_request.number }}
- name: Teardown CI database
if: always()
run: python scripts/ci/teardown_ci_db.py --pr ${{ github.event.pull_request.number }}✓ dbt compile 12s
✓ dbt run (4 models) 48s ← slim CI: 4 of 150 models
✓ dbt test (4 models) 22s
✓ schema change check 3s — no breaking changes detected
✓ teardown 4s
Total: 1m 29sSlim CI — how state:modified+ and --defer actually work together
State-based selection compares the PR’s manifest to a reference manifest from the last production run. Only changed models — plus their dependents — are selected.
PR changes: silver.orders
state:modified+ selects:
silver.orders ← changed directly
gold.daily_revenue ← downstream of silver.orders
gold.customer_ltv ← downstream of silver.orders
gold.fct_orders_wide ← downstream of silver.orders
Skips: silver.customers, silver.payments, and all unrelated gold models.
Runs 4 models instead of 150. CI time: ~1 min instead of 45 min.The remaining problem: silver.orders reads from bronze.orders, which isn’t part of this run’s selection and doesn’t exist in the CI schema. --defer tells dbt to read unselected upstream models from production instead of failing.
# Without --defer:
# silver.orders → tries freshcart_ci_pr_142.bronze.orders → NOT FOUND → error
# With --defer --state ./prod_artifacts:
# silver.orders → reads freshcart_prod.bronze.orders → works
# prod_artifacts/manifest.json is the reference — kept current in S3:
aws s3 cp s3://freshcart-ci-artifacts/dbt/manifest.json ./prod_artifacts/ # at CI start
aws s3 cp ./target/manifest.json s3://freshcart-ci-artifacts/dbt/ # after every prod deployDetecting breaking schema changes automatically
def detect_breaking_changes(current_manifest: dict, prod_manifest: dict) -> list[str]:
breaking = []
for node_id, node in prod_manifest['nodes'].items():
if node_id not in current_manifest['nodes']:
breaking.append(f"Model removed: {node['name']}")
continue
prod_cols = {c: v['data_type'] for c, v in node.get('columns', {}).items()}
current_cols = {c: v['data_type'] for c, v in current_manifest['nodes'][node_id].get('columns', {}).items()}
for col, dtype in prod_cols.items():
if col not in current_cols:
breaking.append(f"{node['name']}.{col} removed")
elif current_cols[col] != dtype:
breaking.append(f"{node['name']}.{col}: {dtype} → {current_cols[col]}")
return breakingBREAKING SCHEMA CHANGES DETECTED:
- gold.daily_revenue.net_revenue removed
If this is intentional, update all downstream consumers first.Deploying to Production — Safe Deployment Patterns for dbt
A full dbt run on production tables that takes 3 hours cannot be rolled back instantly if a bug is found 2 hours in. Safe deployment patterns reduce blast radius and enable fast recovery.
Strategy 1 — direct deployment, for most changes
on:
push:
branches: [main]
jobs:
deploy:
steps:
- uses: actions/checkout@v4
- run: |
dbt deps
dbt run --target prod
dbt test --target prod
- run: aws s3 cp ./target/manifest.json s3://freshcart-ci-artifacts/dbt/Strategy 2 — blue-green, for high-risk Gold changes
def blue_green_deploy_gold_model(model_name: str, run_date: str):
# Step 1: build in a shadow schema — not live to analysts yet
subprocess.run(['dbt', 'run', '--target', 'prod', '--select', model_name,
'--vars', json.dumps({'run_date': run_date, 'target_schema': 'gold_shadow'})], check=True)
# Step 2: test the shadow schema before anyone sees it
subprocess.run(['dbt', 'test', '--target', 'prod', '--select', model_name,
'--vars', json.dumps({'target_schema': 'gold_shadow'})], check=True)
# Step 3: atomic swap — analysts see the new version immediately
conn.execute("BEGIN;")
conn.execute("ALTER SCHEMA freshcart_prod.gold RENAME TO freshcart_prod.gold_old_20260317;")
conn.execute("ALTER SCHEMA freshcart_prod.gold_shadow RENAME TO freshcart_prod.gold;")
conn.execute("COMMIT;") # both renames atomic — never a window with no 'gold' schema
# Step 4: keep the old schema for 24h, then drop it
schedule_schema_drop('gold_old_20260317', delay_hours=24)Strategy 3 — incremental deployment, for schema migrations on huge tables
-- Step 1: add the column as nullable (this dbt run) — analysts see NULL, no breakage
-- Step 2: backfill as a SEPARATE job, so it doesn't lock the table for 3 hours
UPDATE silver.orders SET tip_amount = 0.0
WHERE tip_amount IS NULL AND created_at < '2026-03-17'; -- rows before the feature launch
-- Step 3: only once backfill is complete, add not_null to schema.ymlgit revert and redeploy; a large data corruption → Delta Lake’s RESTORE TABLE ... TO VERSION AS OF; a bad Gold deploy that used blue-green → swap the schema back.Airflow Deployment — DAG Versioning and Safe Updates
A DAG change takes effect the next time the scheduler parses it — typically within 30 seconds. If it modifies a DAG that’s currently running, the in-progress run may behave unexpectedly.
Git Sync — the common path, and its real risk
# Used by Cloud Composer, MWAA, Astronomer:
# push to main → CI passes → Git Sync detects the change → scheduler re-parses → live
# RISK: no staging step for Airflow DAGs.
# A syntax error makes the DAG disappear from the UI entirely.
# A schedule change takes effect immediately — possibly mid-run.
# MITIGATION:
# python -m py_compile dags/*.py — catch syntax errors before merge
# airflow dags list-import-errors — catch import errors before merge
# pause the DAG for genuinely risky changes: pause → deploy → verify → unpauseVersioning the DAG ID for breaking schedule or structure changes
# RISKY: modifying the existing DAG's schedule mid-stream
# DAG('freshcart_morning_pipeline', schedule='0 2 * * *', ...) → schedule='0 6 * * *'
# a run already in progress sees the new schedule on its next evaluation
# SAFER: version the DAG ID
DAG('freshcart_morning_pipeline_v2', schedule='0 6 * * *', ...)
# v1 finishes its current cycle undisturbed; v2 starts fresh on the new schedule
# once v1 has no more in-progress runs, delete itCI checks for DAG files, and the unit tests that catch structural bugs
- run: flake8 dags/ --max-line-length=120
- run: |
for f in dags/*.py; do python -m py_compile "$f" && echo "OK: $f"; done
- run: airflow db init && airflow dags list-import-errors
- run: python scripts/ci/validate_dag_structure.py # unique task IDs, no cycles, start/end present
- run: pytest tests/dags/ -vfrom airflow.models import DagBag
def test_freshcart_pipeline_dag_structure():
dagbag = DagBag(dag_folder='dags/', include_examples=False)
dag = dagbag.get_dag('freshcart_morning_pipeline')
assert dag is not None, "DAG not found"
assert len(dagbag.import_errors) == 0, f"Import errors: {dagbag.import_errors}"
task_ids = [t.task_id for t in dag.tasks]
assert 'dbt_silver' in task_ids and 'dbt_gold' in task_ids
def test_freshcart_pipeline_task_order():
dag = DagBag(dag_folder='dags/').get_dag('freshcart_morning_pipeline')
silver, gold = dag.get_task('dbt_silver'), dag.get_task('dbt_gold')
assert gold.task_id in [t.task_id for t in silver.downstream_list]
def test_schedule_is_set():
dag = DagBag(dag_folder='dags/').get_dag('freshcart_morning_pipeline')
assert dag.schedule_interval is not None
assert dag.catchup is False, "catchup must be False in production DAGs"$ pytest tests/dags/ -v
test_freshcart_pipeline_dag_structure PASSED
test_freshcart_pipeline_task_order PASSED
test_schedule_is_set PASSED
========================== 3 passed in 0.41s ===========================Testing Strategies for Data Pipelines — Unit, Integration, and E2E
The testing pyramid for data pipelines is inverted compared to software: integration and end-to-end tests provide more value than unit tests, because most bugs live at the boundary between SQL and data, not in pure logic.
End-to-end (full pipeline, prod-like data, validated outputs) ← most valuable, slowest
Integration (dbt tests against real data volumes) ← good coverage, medium speed
Unit (pure Python — validators, hash key generators) ← least valuable alone, fastest
Do NOT try to unit-test SQL by mocking the database — that doesn't work.Unit tests — for the Python logic, not the SQL
from pipeline.validate import validate_order_row
def test_valid_order_passes():
row = {'order_id': 9284751, 'customer_id': 4201938, 'order_amount': 380.00, 'status': 'delivered'}
result = validate_order_row(row)
assert result.is_valid, f"Expected valid, got: {result.error}"
def test_negative_amount_rejected():
row = {'order_id': 1, 'customer_id': 1, 'order_amount': -10, 'status': 'placed'}
result = validate_order_row(row)
assert not result.is_valid and result.error_type == 'negative_amount'
def test_hash_key_is_deterministic():
from pipeline.vault import compute_hub_hk
assert compute_hub_hk('4201938') == compute_hub_hk('4201938')
assert compute_hub_hk('ST001') == compute_hub_hk(' st001 ') # normalised before hashingIntegration tests — dbt tests against real production data volumes
Run in CI against the Zero-Copy Clone. This is where most real bugs get caught: a not_null test that passes on 1,000 dev rows can fail on 50 million production rows with edge cases dev never had.
$ dbt test --target ci --select state:modified+
FAIL not_null_silver_orders_customer_id (12 rows)
# passed locally on a 1,000-row dev sample — these 12 nulls only exist in productionEnd-to-end tests — the whole pipeline, validated against business invariants
def test_morning_pipeline_e2e(snowflake_conn, dbt_runner):
test_date = date.today() - timedelta(days=1)
result = dbt_runner.run(select='staging.* silver.* gold.*', vars={'run_date': str(test_date)}, target='ci')
assert result.success, f"Pipeline failed: {result.errors}"
rows = snowflake_conn.execute(
f"SELECT COUNT(*) FROM ci_pr_142.gold.daily_revenue WHERE order_date = '{test_date}'").scalar()
assert 40_000 < rows < 100_000, f"Unexpected row count: {rows}"
negative_revenue = snowflake_conn.execute(
f"SELECT COUNT(*) FROM ci_pr_142.gold.daily_revenue WHERE net_revenue < 0").scalar()
assert negative_revenue == 0
# Bronze = Silver + DLQ — every extracted row is accounted for somewhere
bronze = snowflake_conn.execute(f"SELECT COUNT(*) FROM ci_pr_142.bronze.orders WHERE _bronze_date = '{test_date}'").scalar()
silver = snowflake_conn.execute(f"SELECT COUNT(*) FROM ci_pr_142.silver.orders WHERE DATE(created_at) = '{test_date}'").scalar()
dlq = snowflake_conn.execute(f"SELECT COUNT(*) FROM ci_pr_142.pipeline.dead_letter_queue WHERE run_date = '{test_date}'").scalar()
assert bronze == silver + dlq, f"Row count mismatch: {bronze} bronze != {silver} silver + {dlq} dlq"$ pytest tests/e2e/test_morning_pipeline.py -v
test_morning_pipeline_e2e PASSED
# bronze=48234, silver=48222, dlq=12 → 48234 == 48222 + 12 ✓The Complete CI/CD Flow — From Commit to Production
| Stage | Trigger | What runs | Blocks merge? | Time |
|---|---|---|---|---|
| Pre-commit | git commit (local hook) | sqlfluff lint, black format check, py_compile DAG files | No (local only) | < 5s |
| PR opened | pull_request event | Create Zero-Copy Clone CI DB, dbt deps, compile | Yes if compile fails | 2 min |
| PR CI tests | pull_request (push) | dbt run state:modified+ --defer, dbt test state:modified+, schema change detection, DAG unit tests | Yes if tests fail | 4-8 min |
| PR review | Human approval | Code review, data contract check, downstream impact review | Yes (1 approval required) | Human |
| Merge to main | PR merged | Production dbt run, dbt test --target prod, update prod artifacts in S3, teardown CI DB | Auto-merge blocked if CI fails | 10-30 min |
| Post-deploy | Successful prod run | Notify Slack #deploys, run post-deploy smoke tests, update monitoring dashboard | No | 2 min |
Five Misconceptions About Data CI/CD
A Schema Change That Broke Three Dashboards — And How CI Would Have Prevented It
A data engineer renames net_revenue to revenue_after_discount in gold.daily_revenue for clarity. No dbt tests fail. The PR merges. Three Metabase dashboards querying net_revenue directly break immediately. Finance notices at 09:00.
✓ SQL compiled successfully
✓ dbt tests passed (not_null, unique on order_date, store_id)
✗ No check that net_revenue was removed
✗ No check that Metabase uses net_revenue
✗ No breakage visible until the prod deploy already happened
Detection: 45 min (analyst reports broken dashboard). Fix: 20 min (alias added, redeployed).
Total impact: 1h 5min of broken Finance dashboards in the morning.Fix 1 — schema change detection on every PR
def check_for_breaking_column_changes():
prod = load_manifest('./prod_artifacts/manifest.json')
current = load_manifest('./target/manifest.json')
changes = detect_breaking_changes(prod['nodes'], current['nodes'])
if changes:
print("BREAKING SCHEMA CHANGES DETECTED:")
for c in changes:
print(f" - {c}")
print("If this is intentional, update all downstream consumers first.")
sys.exit(1)Fix 2 — the backward-compatible migration pattern, going forward
-- This PR: both columns exist, nothing breaks
order_amount - discount_amount AS revenue_after_discount,
order_amount - discount_amount AS net_revenue, -- backward-compat alias
-- Next PR, after all dashboards have migrated: remove the net_revenue aliasNext time a Gold column is renamed:
CI fails with: "BREAKING SCHEMA CHANGES: net_revenue removed"
Developer sees: ["Metabase: Daily Revenue dashboard", "CFO Report export"]
Cannot merge until consumers are updated or the PR adds a backward-compat alias.
Zero production breakages from schema changes since.5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Data pipeline CI/CD has higher stakes than software CI/CD. A software bug surfaces as a visible error. A data bug looks like normal data but produces wrong numbers — discovered hours or days later after decisions have been made. This asymmetry demands rigorous testing before production deployment.
- ✓Three environments: Dev (individual developer sandbox, small data subset, isolated schema), Staging/CI (Zero-Copy Clone of production data, isolated per PR, created on PR open and torn down after merge), Production (full data, pipeline service accounts only, no direct developer write access).
- ✓Snowflake Zero-Copy Clone creates an instant snapshot of a production database at zero storage cost. Use it to give each CI run an isolated environment with production-like data. Creating a 10 TB clone takes seconds and costs nothing until the CI run writes to it. Tear down after merge to avoid accumulating idle clones.
- ✓Slim CI uses --select state:modified+ to run only changed models and their downstream dependents. The --defer flag uses production data for upstream models not in the CI selection. Together: CI runs 4-8 minutes instead of 45+ minutes. The prod_artifacts/manifest.json (updated after every successful prod run) provides the reference state for change detection.
- ✓Schema change detection compares the current manifest to the production manifest and fails CI if any Gold column was removed or renamed. This is the most important CI check for preventing broken dashboards. A column rename must go through a deprecation cycle: add the new name, keep the old name as an alias, notify consumers, remove the old name only after all consumers migrate.
- ✓Airflow DAG CI: compile with py_compile (syntax), import with DagBag (catches missing modules), assert DAG structure (expected task IDs, correct dependency order, catchup=False, schedule not None), validate connections exist. Run against the same Docker image as production — a package installed in CI but not production causes the DAG to disappear from the UI after deployment.
- ✓Blue-green deployment for high-risk Gold changes: build the new version in a shadow schema, run tests against it, then atomically swap shadow → production using a transaction. The old schema is preserved for 24 hours as a rollback option. Wrap the schema rename in a BEGIN/COMMIT transaction to make it atomic — non-atomic renames leave a window where no schema exists.
- ✓Rollback strategies: git revert + redeploy (safe and clean, takes 5-10 min), Delta Lake time travel (RESTORE TABLE to a previous version — fast data recovery), blue-green swap back (immediate, no recompute — only if blue-green was used). Choose based on the nature of the problem: logic error → git revert, large data corruption → Delta time travel.
- ✓The data testing pyramid is inverted. Integration tests (dbt tests against real data in CI) provide more value than unit tests because most bugs occur at the boundary between SQL and data. Unit tests are valuable for pure Python logic (validators, hash functions). End-to-end tests validate the full pipeline output against known business invariants.
- ✓The PR process and CI gates are an investment in trust. Analysts who have been burned by wrong data distrust every number. Analysts who trust the data use it confidently and make better decisions. The minutes spent in CI are returned many times over in analyst confidence, fewer post-incident investigations, and stakeholder trust in the data platform.
What comes next
Module 45 covers Infrastructure as Code — provisioning cloud data infrastructure with Terraform so your environments are version-controlled, reproducible, and never subject to configuration drift.
Module 45 → Infrastructure as Code for Data EngineersDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.