Chapter 27 — Key Takeaways (CI/CD for Data Pipelines)
The page to read before designing a pipeline, and again before merging a definitional change.
The claim
A data deploy has two halves and most teams build only one. Shipping the code is genuinely just software. Getting the data into the state the new code implies — a backfill, a migration, a rebuild — is unique to this work, cannot be rolled back, and is usually left to whoever notices.
Four ways this is harder
- You cannot easily test against realistic data — production is where the pathologies live
- The build is slow and expensive — 22 min and $2.93
- There is no rollback for data —
git revertis four minutes and does nothing to rows - Staging is not representative in the one dimension that matters
📐 They have no common fix, and three of the four resolve to "accept something." Data CI/CD is a set of trades. A team that has not named what it gave up gave up something anyway — and the one thing not to trade is speed, because a slow pipeline gets routed around.
Order by yield
1. lint / format 3 s
2. parse 8 s ← Ch. 24 §24.2's parse time
3. POLICY LINTS 12 s ← wall clock, hardcoded refs, uncast money
4. unit tests 25 s
5. slim build 3.1 min
6. full build 22.0 min ← nightly only
Stages 1–4 need no warehouse. 🔎 Kestrel's policy lints caught 14 failures at 12 seconds each; the 4 caught only by the nightly build were all things slim CI structurally cannot see.
Test data: all three, and none is enough
| Fixtures | logic | ✗ shape, cardinality, the real nulls | | Sampled production | shape | ✗ the rare row | | Synthetic | known pathologies | ✗ the one you did not think of |
⚠️ Random sampling preserves the distribution and destroys the tail. A 1% sample contains one of the twelve wholesale distributors with probability $1-0.99^{12} = 11.4\%$.
Sample deliberately: unusual entities in full · the extremes of every measure · every row that has ever failed a test ← build this clause first · then random, for the shape.
And an integration test, nightly, on synthetic data — small, and it exercises the wiring, which breaks on the changes that look safest.
Slim CI
Three requirements: somewhere to keep the production manifest · a production read grant (a governance decision, not a task) · and a fallback to a full build when it is missing.
⚠️ Cannot see: a hardcoded reference (Ch. 19 CS1 — the check that would find it is the one it disables) · a shared target · some macro changes · anything outside dbt. It is a latency optimization, not a coverage one.
Environments
Staging has no clean answer — a small sample misses the row, a full copy is production with the
same PII, synthetic exercises what you thought of. The industry replaced staging with a permission
grant, which is what --defer is.
Kestrel deleted theirs: eighteen months, a 5% sample, never once caught something the slim build did not, $340 a month.
Versioning
Code (git) · schema (forward-only migrations) · and for anything in an external report, the
VALUE. A table of (metric, period, value, computed_at, code_version) is kilobytes a year and is the
only thing that answers "what did we say, and when?" — because a rebuild destroys the number a filing
was made on.
Deploy shape
| Additive | none — the new column is null for history | | Definitional | full rebuild, or history is inconsistent | | Structural | migration + rebuild |
🔁 Require it in the pull request, and for the latter two the rebuild command with a dry run. An author who cannot write that command has not thought about the data half.
Three options when you fix a definition, and exactly one is "do nothing": rebuild · do not rebuild and record the boundary · do not rebuild and do not record it. The third is the absence of a decision, and it is the default because shipping the code is the half with a button.
⚠️ A correct fix that leaves the table inconsistent is worse than the bug. A uniformly wrong column can be corrected; one whose definition changes at an unrecorded date cannot.
🔎 Put _built_by (the git SHA) on the row. 40 bytes, a few kilobytes dictionary-encoded, and it
turns "which code produced this?" from four days into one query. It is not for the analyst.
Rollback
| Code | git revert. Minutes. |
| Table contents | time travel, inside retention |
| A migration | a forward migration; the reverse is often lossy |
| Data sent downstream | nothing |
Make the irreversible step last, after the assertions: build >> test >> reconcile >> export.
Where rollback is unavailable, run both and compare. Shadow-build and diff — expect differences and read them; the gate is "only the ones you intended." Blue-green swap keeps the old table a day.
Reviewing
📐 If a defect is invisible in the artifact under review, no amount of diligence is the fix.
| Delegate to a linter | Keep for a human |
|---|---|
hardcoded refs, wall clock, uncast money, missing max_active_runs, coverage |
the deploy shape · is the definition right · did the grain change · who downstream should be told |
After moving the left column into CI, Kestrel's review comments fell from a median of 4 to 2 and the share about meaning rather than mechanics rose from a quarter to three quarters.
The orchestrator
⚠️ A DAG deploy affects everyone — the scheduler parses every file every 30 seconds. Parse time
is a gate, not a test. Sync atomically. A task rename is a structural change to a historical
record: keep task_id stable and rename the function.
Secrets and speed
Never in the repo, never in profiles.yml, prefer OIDC, and --store-failures so a failing test
does not print production rows into a log everyone can read.
Four levers on speed: slim CI (22 → 3.1 min) · order by yield · cache dbt deps · parallelize the
warehouse-free half.
💸 Put the consequence where the decision is made. A six-line PR comment — blast radius, exposures, deploy shape, margin impact — has never blocked a change, and made four authors find a cheaper approach and three ask a stakeholder before merging, which had never happened and was not requested.
The two case studies
The definition that changed on a Tuesday. A correct three-line fix, two reviewers, all tests green
— and net_revenue_cents meant two different things either side of a deploy timestamp nobody chose.
136,664 lines differed; order totals were right throughout, so nothing reconciled differently.
Classify conservatively: a false definitional costs thirty seconds, a false additive cost eleven
weeks — and measure the override rate, because it is the justification.
The green build that tested nothing. A missing manifest made state:modified+ select nothing, dbt
exit 0, and CI pass in eleven seconds for nine weeks and 63 pull requests.
⚠️ A check whose failure is indistinguishable from having nothing to check will fail open — an empty suite passes,
PASS=0 TOTAL=0exits 0. Assert that the check RAN, not that it passed.And it got faster when it broke, which is why it survived nine weeks: a control that gets slower when it breaks is investigated in a day.
🔎 "Do not X; instead Y" is two requirements, and the negative one is easier, is what a reviewer sees, and is the one implemented alone. Write the positive clause first and test the negative path.
Prefer a loud degraded mode to a blocking one. A blocked person routes around it; an inconvenienced person complains, and a complaint is a detection.