Skip to main content
Data Engineering · lesson 21 of 23
Open the Data Engineering page →
Data Engineering advanced Lesson 21 of 23

CI/CD for Data: Gating Promotion with Real Tests

Promote a staging table to production only when it passes real gate checks, and watch a build with a duplicate key get blocked while a clean build ships.

By SkillByExample · Published

data-engineering ci-cd deployment testing

Promote a staging table to production only when it passes real checks, then watch a broken build get blocked automatically instead of reaching anyone querying prod.

Two environments, one gate function

import duckdb
con = duckdb.connect()
con.execute("create schema staging")
con.execute("create schema prod")

# prod already has yesterday's good data
con.execute("create table prod.orders as select * from (values (1, 25.5), (2, 12.0)) t(order_id, amount)")

def run_gate_checks(schema_table):
    checks = con.execute(f"""
        select count(*) as rows,
               count(*) filter (order_id is null) as null_ids,
               count(*) - count(distinct order_id) as duplicate_ids
        from {schema_table}
    """).fetchone()
    rows, nulls, dupes = checks
    failures = []
    if rows == 0:
        failures.append("zero rows - refusing to promote an empty table")
    if nulls:
        failures.append(f"{nulls} null order_id(s)")
    if dupes:
        failures.append(f"{dupes} duplicate order_id(s)")
    return failures

def deploy(candidate_schema, prod_schema, table):
    failures = run_gate_checks(f"{candidate_schema}.{table}")
    if failures:
        print(f"BLOCKED: {', '.join(failures)}")
        print(f"  {prod_schema}.{table} left untouched")
        return False
    con.execute(f"create or replace table {prod_schema}.{table} as select * from {candidate_schema}.{table}")
    print(f"DEPLOYED: {candidate_schema}.{table} promoted to {prod_schema}.{table}")
    return True

A clean build promotes normally

con.execute("create or replace table staging.orders as select * from "
            "(values (1, 25.5), (2, 12.0), (3, 8.75)) t(order_id, amount)")
deploy("staging", "prod", "orders")
print(f"prod.orders now has {con.execute('select count(*) from prod.orders').fetchone()[0]} rows")
$ python cicd.py
== good candidate build: three clean rows ==
DEPLOYED: staging.orders promoted to prod.orders
prod.orders now has 3 rows

A build with a bug gets blocked before it reaches prod

con.execute("create or replace table staging.orders as select * from "
            "(values (1, 25.5), (2, 12.0), (2, 999.0)) t(order_id, amount)")
deploy("staging", "prod", "orders")
print(f"prod.orders still has {con.execute('select count(*) from prod.orders').fetchone()[0]} rows "
      f"(the good build from before, untouched)")
== broken candidate build: a bug introduces a duplicate order_id ==
BLOCKED: 1 duplicate order_id(s)
  prod.orders left untouched
prod.orders still has 3 rows (the good build from before, untouched)

Order 2 appears twice in the second build — some upstream join bug, easy to imagine shipping unnoticed. run_gate_checks catches it before deploy ever runs the promotion statement, so prod.orders stays exactly as it was: 3 rows, no duplicates, from the last build that actually passed. Nobody querying prod ever sees the broken version. This is the same shape as a CI pipeline blocking a merge on a failing test suite — the “test suite” here just happens to run against real data instead of only against code.

Practice

1. Add a check that rejects any `amount` below zero, and test it against a build with a negative amount.
BLOCKED: 1 negative amount(s)
  prod.orders left untouched

Same pattern, one more row in checks, one more if in run_gate_checks — the gate function does not need to change shape to grow new rules.

2. What happens if `deploy` is called on a `staging` table that does not exist yet?

run_gate_checks raises a duckdb.CatalogException (table not found) before it ever gets to return a failures list — an unhandled exception, not a clean “BLOCKED” message. A real deploy gate should catch this explicitly and treat “the build never produced output at all” as its own failure mode, distinct from “the build produced bad output.”

3. Change `deploy` to keep the previous prod version accessible after a successful promotion (for rollback).
def deploy(candidate_schema, prod_schema, table):
    failures = run_gate_checks(f"{candidate_schema}.{table}")
    if failures:
        print(f"BLOCKED: {failures}")
        return False
    con.execute(f"create or replace table {prod_schema}.{table}_previous as select * from {prod_schema}.{table}")
    con.execute(f"create or replace table {prod_schema}.{table} as select * from {candidate_schema}.{table}")
    return True

Keeping one prior version around turns “the new build passed checks but is still wrong somehow” into a fast manual rollback instead of a re-run of the entire upstream pipeline.

4. Why gate on the staging table's *content*, not just on whether the pipeline code exited with status 0?

A pipeline can exit successfully while producing wrong data — the duplicate-key build in this lesson ran without crashing. Testing pipeline code (the pipeline-testing lesson) and testing pipeline output are both necessary and catch different bug classes; a green exit code says the code didn’t crash, not that what it produced is safe to promote.

Next: disaster recovery — backup, restore, and the RPO/RTO numbers that decide how much data a platform can afford to lose.

Frequently Asked Questions

How is CI/CD for data different from CI/CD for application code?
The mechanics are the same idea — build, test, gate, promote — but what gets tested is different. Application CI/CD tests code behavior against fixed inputs. Data CI/CD has to test both the pipeline code and the actual data a build produces, because a pipeline can be bug-free and still produce a broken table if the source data changed shape or quality.
What is environment promotion, concretely?
Building a new version of a table in a staging (or 'candidate') location first, running checks against it, and only then making it the production version — usually via a fast rename/swap rather than rebuilding prod in place. If the checks fail, prod is never touched, so a broken build never reaches anyone querying the production table.
How is this different from the schema-evolution-and-contracts lesson's 'Enforcing it in CI' section?
That section checks a schema contract specifically — do the columns and types still match what was promised. This lesson's gate checks are about the promotion decision more broadly (row count, key uniqueness, and would typically include the schema check too) — schema contract enforcement is one input into a deploy gate, not the whole gate.
What happens to a blocked build in a real system?
It stays in staging, unpromoted, and typically triggers an alert (see the observability lesson) so someone investigates before the next scheduled run tries again. The pipeline doesn't retry into production blindly — a human decides whether to fix the source, fix the code, or override the gate deliberately.