BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Drill 57 — The Rollback That Couldn't

Theme: Migrations & Releases · Format: 20-min drill · Difficulty: advanced

The story

A release bundles an application deploy with a schema change: a legacy column is rewritten into a new format and then dropped, all in one PR (pull request), one migration, one release. The reviewer praises the tidiness — no dangling halfway states, no follow-up tickets. Within hours a serious bug surfaces in the new application version. The rollback plan says "redeploy the previous version," but the previous version reads the column that no longer exists — rollback is structurally impossible. The team fixes forward under fire; it takes 14 hours.

⏸ Stop here and ask the room: the rollback plan was written down and reviewed. Why was it a lie?

Run it — facilitator script

Timing: 2 min story · 10 min questions · 3 min reveal · 5 min transfer.

"What exactly made this release irreversible — the code, the schema change, or the coupling?"

Listen for: the coupling — the code alone rolls back with a redeploy, and even the schema change would have been survivable if the old column had still existed. Bundling a destructive schema step with the app deploy is what welded the two together. Sharpest answers add the time dimension: the release destroyed the state its own rollback depended on.

Weak signal: "The schema change — never drop columns." Too blunt. Columns do eventually get dropped; the discipline is when and with what — a drop weeks later, in its own release, is fine. Blaming the drop itself misses the sequencing lesson.

"What order of operations keeps every step independently reversible?"

Listen for: expand-contract walked in order — add the new column alongside the old; ship code reading both formats; migrate the data; ship code writing only the new format; drop the old column in a later release once nothing references it. The test someone should articulate: at every step, the previous app version still runs against the current schema.

Weak signal: a vague "do it in stages." How many stages, and what makes a stage boundary safe? If they can't state the invariant — schema always compatible with both the current and previous code version — the stages are just a slower version of the same coupling.

"What's the standing rule about destructive schema changes and app deploys?"

Listen for: a rule crisp enough to enforce mechanically — destructive schema changes (drops, renames, type-narrowing) never ship in the same release as application code; the destructive step always ships last, and alone, after a soak period. Bonus: someone proposes the CI (continuous integration) check that blocks the combination automatically.

Weak signal: "We'd flag it in review." This release was reviewed — and praised. A rule that lives in reviewer judgment loses to tidiness aesthetics every time; it has to live in tooling or in a checklist with teeth.

The reveal

The coupling was chosen deliberately, for tidiness — "one PR, one release" — and everyone involved considered it good engineering hygiene. Tidiness cost the team its rollback option at exactly the moment the option was worth 14 hours of outage.

Why this happens — the mechanics

The trap here is that the dangerous choice looks like the disciplined one. Review culture rewards atomic changes: one PR that tells a complete story, no halfway states, no follow-up tickets that might rot in the backlog. Those instincts are right for code, where "atomic" means revertable in one step — and exactly wrong at the code-schema boundary, where the database carries state that a git revert cannot restore. The reviewer praised tidiness because on every axis the review process measures, tidiness is quality. Nobody's checklist measured reversibility.

Rollback options don't announce their own death — they decay silently, one coupling decision at a time. The rollback plan said "redeploy the previous version" and was written against the code diff, implicitly assuming the world the previous version needs would still exist. Destructive operations are the special case that breaks the assumption: a drop is the one step whose undo requires information that is simply gone. Every other step in this release was reversible on its own; the drop, welded to the deploy, converted an ordinary app bug into a structural trap. And no tool flagged the moment the option died — rollback plans are prose, and prose doesn't fail CI.

Expand-contract feels bureaucratic precisely because its cost is visible and its benefit is invisible: four small boring releases instead of one satisfying one, plus a follow-up ticket, plus weeks of an "ugly" duplicated column. The team pays that overhead on every migration; the payoff arrives only on the release where something breaks — at which point rolling back one small step costs minutes, not 14 hours of fixing forward under fire. Like insurance, it's only ever a waste in the timelines where nothing goes wrong.

Diagrams

Problem — how the failure unfolds:

sequenceDiagram
    autonumber
    participant RL as Release
    participant SC as Schema
    participant BG as App bug
    participant RB as Rollback attempt
    RL->>SC: One PR - app deploy plus<br/>rewrite and drop column
    Note over SC: Old column gone -<br/>rollback path destroyed<br/>(silent)
    Note over RL: Reviewer praises the<br/>tidiness
    BG->>RL: Serious bug surfaces<br/>within hours
    RL->>RB: Plan says redeploy the<br/>previous version
    RB--xSC: Old code reads the<br/>dropped column
    Note over RB: Rollback structurally<br/>impossible
    Note over RL,RB: Fix-forward under fire -<br/>14 hours

Solution — where the guardrails go:

flowchart TD
    A["Release 1: add new column,<br/>keep the old one"] --> B["Release 2: code reads<br/>both, writes both"]
    B --> C["Backfill migrates old data<br/>to the new format"]
    C -.-> V["Reconciliation verifies<br/>both columns agree"]
    V -.-> AL["Divergence blocks the next<br/>stage"]
    C --> D["Release 3: code writes new<br/>format only"]
    D --> E["Release 4, weeks later:<br/>drop the old column"]
    D -.-> R["Bug in any release: roll<br/>back that release alone"]
    E --> F["Destructive step ships<br/>last, and alone"]

What good looks like

The lesson

Use the expand-contract pattern (also called parallel change): expand — add the new column and ship code that reads both formats; migrate the data; ship code that writes only the new format; then contract — drop the old column in a later release, once nothing references it. Every step is small, boring, and independently reversible. Never couple a destructive schema change to an app deploy; the destructive step always ships last, and alone.

Named concepts: expand-contract (parallel change), rollback-safety, decoupled deploys, fix-forward readiness.

Real-world echoes

Transfer

Look at our next release that touches both code and schema. For each step, complete the sentence "we undo this by ___" — any blank is the drill.

Make it a ticket: Add the "we undo this by ___" field to our release template and write the CI rule that blocks destructive schema statements from shipping in the same changeset as application code — with the destructive-step soak delay documented as the override path.

Related