Data Engineering · lesson 21 of 23
Beginner
8- 1 Data Engineering Fundamentals
- 2 File Formats: CSV, JSON, Parquet
- 3 Idempotency and Safe Retries
- 4 Batch Pipelines and Time Windows
- 5 Partitioning and Physical Layout
- 6 Batch vs. Streaming: The Real Trade-off
- 7 Data Lake vs. Data Warehouse vs. Lakehouse
- 8 Data Modeling Basics: Star Schemas and Denormalization
Intermediate
4Advanced
11- 13 Streaming: Exactly-Once Semantics
- 14 Schema Evolution and Data Contracts
- 15 Streaming Architecture: Lambda and Kappa
- 16 Data Lineage: Tracing a Column Back to Its Source
- 17 Data Governance: Catalogs, Classification, and Access Policy
- 18 Data Security and PII: Hashing, Masking, and Tokenization
- 19 Pipeline Observability: Tracing, Anomaly Detection, and Alerting
- 20 Cost Optimization: Measuring What Partition and Column Pruning Actually Save
- 21 CI/CD for Data: Gating Promotion with Real Tests
- 22 Disaster Recovery: RPO, RTO, and What a Backup Actually Loses
- 23 Data Platform Architecture: Wiring the Pieces Together
Also part of Data Engineering
9- + Your First Airflow DAG from Airflow
- + Kafka Topics, Partitions, and Offsets from Kafka
- + Spark Architecture: Driver, Executors, and Cluster Managers from Apache Spark
- + Models, ref, and the DAG from dbt
- + Delta Lake: The Transaction Log from Databricks
- + The Medallion Architecture from Databricks
- + File I/O, pathlib, and JSON in Python from Python
- + Error Handling in Python from Python
- + Terraform Fundamentals (IaC) from Terraform
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
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.