The Rollback That Doesn't Work: Testing Deployment Reversibility as a Release Requirement
A platform team at a mid-sized SaaS company had, by most measures, a mature deployment pipeline. Every pull request ran a full test suite. Every merge to main triggered a staging deployment with automated smoke tests. Every production release went out through a canary stage, watched for fifteen minutes against a set of latency and error-rate dashboards before widening to full traffic. The team had invested two years of engineering effort into that pipeline, and it showed: their change failure rate was low, their deploy frequency was high, and nobody on the team could remember the last outage caused by a bad forward deployment.
Then a release shipped with a subtle bug in how it handled a specific currency-conversion edge case, caught forty minutes after full rollout by a finance team member who noticed a discrepancy in a reconciliation report, not by any dashboard. The on-call engineer did what the runbook told her to do: roll back. The deployment tool reported success within ninety seconds. The dashboards looked normal. Everyone breathed out.
Twenty minutes later, the application started throwing errors on every write to the orders table. The release that had just been rolled back had included a migration that added a NOT NULL column with no default, and the previous version of the application code — now running again, exactly as designed — had no idea that column existed and had never been taught to populate it. The rollback had worked exactly as the deployment tool understood "rollback" to mean: it had put the old binary back in front of live traffic. It had not restored a working system. Those are not the same thing, and the gap between them is precisely what this article is about.
Nothing about this team's forward-deployment discipline was inadequate. Their canary process, their test suite, and their staging environment were all doing real work and catching real problems before they reached customers. The rollback path sitting next to all of that investment had never been exercised against a schema change, because nobody had ever needed to use it against one before — which is exactly why nobody knew it was broken. A team can be genuinely excellent at testing whether a release works and simultaneously have no functioning answer to whether reversing it does. Both things are true of the same team, on the same day, and the second one is usually discovered during the incident it was supposed to prevent.
This is not a story about carelessness. It is a story about where testing investment naturally accumulates and where it does not. Forward deployment gets tested because it happens constantly — every merge, every day, dozens of times a week for an active team — so any weakness in it surfaces quickly and gets fixed. Rollback gets invoked rarely, exercised even more rarely in any deliberate way, and treated by almost every deployment tool and almost every engineering culture as a property that exists automatically because the tooling supports a command called rollback or undo. The command exists. Whether running it puts the system into a working state is a separate question that the tooling cannot answer for you, because that answer depends on decisions made in application code, database migrations, message contracts, and cache formats — decisions that are usually made without anyone thinking about reversibility at all.
Two Pipelines, One Team, One Blind Spot
It helps to be precise about what "rollback" is actually claiming to do, because the word covers two very different guarantees that get conflated constantly.
The first guarantee, and the one every deployment platform actually provides, is mechanical: put the previous artifact back in front of traffic. Kubernetes calls this kubectl rollout undo, and its documentation describes exactly this scope — the rollout controller maintains a revision history for a Deployment, DaemonSet, or StatefulSet, and rollout undo reverts the pod template to a prior revision in that history, with rollout history available to inspect what those revisions contain and --to-revision available to target a specific one rather than just the immediately preceding version (Kubernetes documentation, kubectl rollout reference). AWS CodeDeploy's automatic rollback behaves the same way at a different layer: when a deployment fails or a configured CloudWatch alarm threshold is breached, CodeDeploy performs the rollback by redeploying the last known good application revision — explicitly as a new deployment with a new deployment ID, not as some kind of system-level "undo" — reapplying the lifecycle scripts and content-handling rules that were in effect for that earlier revision (AWS CodeDeploy documentation, Rollback and redeploy a deployment). That detail is worth sitting with: AWS's own documentation describes a rollback as a new deployment of old code, not a magic reversal. It is going forward, again, with a version you have used before.
The second guarantee is the one people actually mean when they say "we can roll back if something goes wrong," and it has nothing to do with the deployment tool: does putting the old binary back in front of traffic restore a system that behaves correctly against the data, messages, cached values, flag states, and external contracts that exist at that moment? The deployment tool has no way to know or verify this. It can put old code back. It cannot verify that the old code still understands the world it is about to run in, because that world has kept moving while the new code was live, and the tool has no visibility into schema state, message formats, or third-party contract versions. The mechanical guarantee is real and dependable. The behavioral guarantee is what everyone actually needs, and it depends entirely on choices made by the engineering team, not on the deployment platform.
This is why sophisticated organizations that write publicly about release engineering treat rollback as something you practice, not something you assume. Google's engineering team, writing about reliable releases from their Customer Reliability Engineering group, is direct on this point: rollbacks should be normal, not exceptional, and to stay that way they need to be low-risk and easy to execute with confidence — which means testing them deliberately. Their specific recommendation is a rollback drill: if a service has not been rolled back in a while, roll it back "just because," confirm that it still works, and if it does, roll forward again after checking logs. If it does not work, that is valuable information to have discovered on a calm day rather than during an actual incident (Google Cloud Blog, "SRE at Google: Reliable releases and rollbacks," CRE Life Lessons). That single practice — treating rollback as a capability you rehearse, not a fact you assume — is the difference between the team in the opening scenario and one that would have caught the missing-default-value problem in a staging environment instead of in front of live traffic.
Google's article names the two most common reasons rollback stops working in practice: schema changes that the old binary was never tested against, and dependency versions moving out of sync when one service rolls back and another does not. Both deserve a full treatment, because they are two of the five mechanisms responsible for most of the rollback failures engineering teams encounter, and they behave differently enough that a single mitigation will not cover all of them.
What "Rolling Back" Actually Means, Deployment Strategy by Deployment Strategy
Before inventorying what breaks rollback, it is worth being specific about what a rollback command actually does under each of the deployment patterns teams commonly use, because the mechanical action is different in each case and the assumptions engineers carry about "instant" reversal do not hold equally everywhere.
Rolling deployments (Kubernetes-style). A Kubernetes Deployment replaces pods incrementally, and kubectl rollout undo deployment/<name> reverses that process by scheduling pods running the previous ReplicaSet's pod template and scaling down the current one, following the same rolling mechanics in reverse. It is not instantaneous — pods have to start, pass readiness probes, and take traffic before old pods are terminated — and revision history is finite by default, so a team that has deployed many times since the last known-good version may find that revision has already aged out of the history unless revisionHistoryLimit was set generously. The kubectl rollout history command lets you inspect what is actually available to roll back to, and --to-revision lets you target something other than the immediately prior version (Kubernetes documentation, kubectl rollout reference). This matters operationally: a team that assumes "we can always get back one version" without checking their retained revision history may find, mid-incident, that the version they actually need is gone.
Blue/green deployments. In a blue/green model, two complete environments exist simultaneously and traffic is redirected from one to the other, typically at a load balancer or routing layer. Amazon ECS's blue/green deployment support through CodeDeploy follows this shape: a new task set is stood up alongside the running one, traffic is shifted according to a configured strategy (canary, linear, or all-at-once), and a deployment group's rollback configuration governs what happens if the new task set fails (AWS documentation, CodeDeploy blue/green deployments for Amazon ECS). The theoretical appeal of blue/green is that rollback is "just" flipping traffic back, which is fast and clean at the routing layer. The practical limitation is that this only holds for the parts of the system that are actually duplicated blue and green — the application tier. The database, the message queue, the cache, and any third-party integration are almost always shared between both environments, not duplicated, which means the mechanical speed of blue/green rollback at the routing layer says nothing about whether the shared state underneath both environments is still compatible with whichever side you flip back to.
Canary deployments. A canary release exposes a small percentage of traffic to the new version while the majority continues to run the previous one, specifically to detect problems on a small population before they affect everyone. Google's SRE Workbook describes canarying precisely this way: a partial, time-limited deployment evaluated against a control group running the stable version, valuable because smaller, more contained release artifacts are cheaper and safer to withdraw if something goes wrong (Google SRE Workbook, "Canarying Releases"). This is a genuinely effective way to limit blast radius during the detection phase of a bad release. It is worth noting explicitly what it does not solve: a canary is a detection mechanism, not a reversibility mechanism. Catching a problem while it affects five percent of traffic still requires that pulling the canary back to zero percent restores a working system for that five percent, and that guarantee depends on exactly the same schema, flag, queue, cache, and contract compatibility questions as any other rollback. A well-run canary buys you a smaller blast radius and more time to react. It does not, by itself, guarantee that reacting by rolling back will work.
The throughline across all three patterns is the same: the deployment strategy determines how fast and how gracefully old code can be put back in front of traffic. None of the three strategies say anything about whether that old code will function correctly once it is there. That question is answered entirely by what has happened to the data, messages, caches, flags, and external contracts the application depends on — which is the subject of the next section.
Five Ways a Deployment Quietly Loses Its Undo Button
The mechanisms below are not exotic. Each one is the direct result of an engineering decision that is individually reasonable — sometimes necessary — made without considering what happens if the code that made it gets rolled back while the decision's effects are still live.
Illustrative Table 1 — Rollback-Breaking Mechanisms at a Glance
| Mechanism | What changes | Why the old version breaks | Typical detection point |
|---|---|---|---|
| Schema migration | A new column, constraint, or type is added ahead of the code that uses it | Old code does not populate a required field, or writes a value the new constraint rejects | First write after rollback, often within minutes |
| Feature flag state | A flag is flipped to a value or variant the old code never handled | Old code branches on flag state it was never tested against, or a flag it doesn't recognize | Inconsistent, often intermittent, tied to which users hit which flag evaluation |
| Message/event schema | A queue or event payload adds required fields or changes field semantics | Old consumer code fails to deserialize, or silently misreads a changed field | Consumer error rate spike or silent data corruption, sometimes delayed by queue depth |
| Cache entries | Cached values are written in a new serialization format or shape | Old code cannot parse the new cache entry and either errors or silently misreads it | Immediate on cache read, but often masked until cache entries from the new version dominate |
| Third-party webhook contracts | An external provider's payload version advances independently of your deployment | Old code expects a payload shape the provider stopped sending, or vice versa | Delayed, tied to the provider's own release schedule, not yours |
This table is a structural map, not a ranked severity list — which mechanism matters most depends entirely on a given system's architecture. The subsections that follow take each one in turn, with a worked example for the three that cause the most damage in practice: schema, event/queue, and third-party contract changes.
Schema migrations that only work in one direction
This is the mechanism from the opening scenario, and it is the most common one because database schema changes are unavoidable and usually treated as a solved problem once the migration itself runs without error.
The specific pattern that breaks rollback is deploying application code and a schema migration together, where the migration adds a constraint the old code cannot satisfy. A NOT NULL column with no default is the canonical example, but the same failure shape appears with a new foreign key constraint that old code's write path never populates, a column type change that the old code's ORM layer serializes incorrectly, or a uniqueness constraint on a field the old code writes non-uniquely. In every case, the migration itself succeeds — it is a schema change, not a data operation, and it runs against an empty or default-compatible table without complaint. The failure only appears the first time the rolled-back application tries to write a row, and by then the migration has already been live for however long the bad release was in production, which could be minutes or could be most of a day.
Hypothetical example — fintech ledger service. A payments platform's engineering team is adding support for multi-currency settlement. The migration adds a settlement_currency column to the transactions table with a NOT NULL constraint, reasoning that every new transaction must specify a currency and there is no sensible default. The application code deployed alongside the migration populates the field correctly on every write path. The release passes code review, staging tests, and a canary window with no errors, because canary traffic is served exclusively by the new version, which always populates the column.
Two hours after full rollout, a downstream reconciliation job — unrelated to the deploy, running on its own schedule — flags a mismatch that traces back to a transaction type the new code handles differently than expected under a specific fee-waiver condition. The on-call engineer rolls back. The previous application version has no knowledge that settlement_currency exists; its transaction-write code path was never touched by this change and has no field mapping for it. Every write to transactions now fails a NOT NULL constraint the currently running code has never heard of. The hidden assumption was that "the migration ran cleanly" meant "the schema change is safe" — but a clean migration only tells you the change was applied, not that every version of the application that might run against that schema afterward can still write to the table. The consequence is a full write outage on the ledger, discovered only because the team tried to undo an unrelated bug and could not. The better approach — covered in detail in the next section — is separating the schema change from the behavior change across multiple releases, so that no single rollback target is ever incompatible with the schema state it will find.
Feature flags left in a state the old code has never seen
Feature flags are usually framed, correctly, as a tool that reduces deployment risk by decoupling code deployment from feature activation. What gets missed is that a flag's state is itself a piece of runtime configuration the old binary has to interpret correctly, and if a flag was introduced or its allowed values changed as part of the same release being rolled back, the old binary may not know what to do with the state the flag is left in.
The most common version of this is a flag introduced specifically to support the new release — a settlement_currency_v2 flag, for instance — that gets left in its "on" position when only the application code is rolled back, because rolling back a flag is a separate action from rolling back a deployment and often lives in a different system entirely, managed by a different team, with no automated linkage to the deploy pipeline. The old code either does not recognize the flag key at all (usually harmless, since most flag SDKs default to a safe fallback when a key is unknown) or, more dangerously, recognizes the key but was written against an earlier version of what that flag's values mean, producing behavior nobody intended and nobody tested. This is a narrower and more specific failure than the general problem of flag combinatorics across a large flag estate — that is a distinct and separately significant subject in its own right — but the rollback-specific version of it is worth calling out on its own: a flag rollback is not automatic just because a code rollback happened, and a deployment runbook that says "roll back the release" without also specifying "and revert any flags introduced by it" leaves a gap that only shows up when someone actually needs to use it.
Message and event schema changes that queued messages can't survive
Asynchronous systems add a dimension that synchronous request/response systems do not have: messages produced by the new version of a service can sit in a queue or event stream for seconds, minutes, or hours before a consumer reads them, and if that consumer gets rolled back to an older version in the meantime, it has to correctly handle messages that were produced by code it no longer contains.
This is well understood in the schema registry world. Confluent's documentation on schema evolution for Kafka describes compatibility modes precisely because this problem is common enough to need a formal solution: backward compatibility means a consumer using the new schema can read data produced with the old schema, forward compatibility means a consumer using the old schema can read data produced with the new schema, and full compatibility requires both directions to hold simultaneously (Confluent documentation, "Schema Evolution and Compatibility"). A team that ships a new required field on an event payload without forward-compatibility in mind has, in effect, made the same mistake as the NOT NULL schema migration, just at the message layer instead of the database layer: the new producer's messages are perfectly valid to the new consumer and completely unreadable, or silently misread, by the old one.
Hypothetical example — e-commerce order-fulfillment queue. An e-commerce platform's fulfillment service publishes an order.ready_for_pick event to a queue consumed by a warehouse-integration service. A release adds a new required field, fulfillment_priority_tier, used to route orders to different warehouse zones, and the schema is versioned in a way that marks the field as required rather than optional. The producer starts emitting the new field the moment the release goes live. Thirty minutes in, an unrelated bug in the same release causes duplicate fulfillment events for gift-wrapped orders, and the team rolls back the fulfillment service that produces these events. The warehouse-integration consumer, which was never touched by this rollback and is still running its own current version, keeps consuming from the same queue — and now encounters a backlog of messages that mixes events with the new required field and events without it, because messages already in flight before the rollback still carry the new shape while messages produced after the rollback do not. Depending on how strictly the consumer's deserialization is written, this produces either a spike of message-processing errors or, worse, silently missing priority-tier data that causes misrouted fulfillment with no error at all. The hidden assumption was that rolling back the producer immediately reverts the shape of every message in the pipeline; queues do not work that way, because they retain what was already written. The better approach is to make new required fields optional with a safe default for at least one full release cycle — the message-layer equivalent of expand/contract, covered later in this article — so that a consumer written against either the old or the new schema can read anything the queue currently holds.
Cache entries written in a format the rolled-back code can't parse
Caches are often the least-scrutinized layer in a rollback analysis because they are treated as disposable — "just clear the cache" is a common instinct. That instinct is correct for caches storing simple, unversioned values. It breaks down for caches storing serialized objects, computed aggregates, or structured data whose shape changes between releases, because a cache entry written by the new version in a new format is not something the old version can simply ignore; if the old version reads that key and gets back a shape it does not expect, the failure mode depends entirely on how defensively the deserialization code was written, and defensive cache-read code is not something most teams prioritize, because caches are assumed to be internal implementation detail rather than a compatibility surface.
The practical fix here is narrower than the schema and queue cases, because caches are, definitionally, more disposable than a system of record: a cache-format change should be paired with either a key-namespace version bump (so old and new code read from different keys and never collide) or a rollback runbook step that explicitly flushes the affected cache keys as part of the rollback procedure. Neither fix is difficult. Both are frequently skipped, because the change that introduced the new cache format rarely gets flagged internally as a "compatibility-sensitive" change — it looks like an ordinary refactor of how a value gets computed and stored, not like a schema migration, even though it carries exactly the same reversibility risk.
Third-party webhook and API contracts that move on their own schedule
The final mechanism is the one engineering teams have the least control over, because it does not depend on their own release cadence at all. A payment processor, an identity provider, a shipping carrier, or any other third-party integration can advance its webhook payload version, deprecate a field, or change semantics on its own timeline, independent of when the integrating team last deployed. If a team's current production code was written to handle the provider's latest payload version, and that code gets rolled back to a version written against an earlier payload shape, the rollback target is now incompatible with the live contract the third party is actually sending — not because anything about the rollback itself went wrong, but because the external world kept moving while the internal deployment history stood still.
This is structurally the same failure as the schema and event cases, but it deserves separate treatment because the fix is different: you cannot apply an expand/contract pattern to someone else's API. The practical mitigation is defensive parsing on every third-party webhook consumer — treat unknown fields as ignorable rather than fatal, treat missing optional fields as acceptable, and version-pin against the provider's documented compatibility guarantees rather than assuming their contract is frozen in time. A rollback plan that assumes "the external contract will still match whatever version we're rolling back to" is making an assumption the team has no way to enforce, which is exactly why it needs to be defended against in the code itself rather than in the rollback runbook.
Hypothetical example — marketplace platform and a payment provider's webhook version. A marketplace platform accepts payments through a third-party processor that sends asynchronous webhooks confirming charge status, payout status, and dispute events. Six months earlier, the processor introduced a new webhook API version that restructured the dispute-event payload, moving a reason_code field from a top-level property into a nested evidence object and adding several new possible values the marketplace's team had not previously needed to handle. The marketplace's engineering team updated their webhook consumer to parse the new nested structure and pinned their integration to the new version through the processor's dashboard, as recommended in the processor's own migration guide. That change shipped, alongside unrelated checkout-flow improvements, in a single release.
Three weeks later, an unrelated regression in the checkout flow's discount-code handling causes double-charging on a small number of transactions, and the on-call engineer rolls the release back, reasoning that the whole release is suspect and reverting it entirely is the fastest way to stop the bleeding. The checkout regression is resolved immediately. The dispute-webhook consumer, however, is now running code written against the processor's previous webhook version — but the processor's dashboard still has the integration pinned to the new version, because that pin lives in the processor's own configuration, not in the marketplace's deployed code, and nothing about an internal kubectl rollout undo touches an external vendor's dashboard setting. Every dispute webhook the processor sends now arrives in the new nested shape, and the rolled-back consumer code, written for the old flat structure, either throws a deserialization error or — in the more dangerous case, if the old code has a permissive parser that silently defaults an unrecognized field — logs disputes with an incorrect or missing reason code. The team does not notice until a support ticket surfaces a dispute that the internal dashboard shows as reason "unknown," three days after the rollback, well outside the window anyone was still watching the release closely.
The hidden assumption here was that a rollback of internal code also reverts every configuration surface the release touched, including ones that live outside the team's own deployment boundary. It does not, and a third-party integration's version pin is exactly this kind of external configuration: changing it typically requires a separate action against the vendor's own systems, on the vendor's own timeline, which an internal rollback command has no way to trigger. The better approach has two parts: first, whenever a release changes which version of a third-party contract the integration targets, treat that version pin itself as part of the deployable unit, with an explicit note in the release plan for how to revert it, not just an assumption that code rollback covers it; second, and more durably, write webhook consumers defensively enough — tolerant of both the old and new payload shapes for a transition window — that a rollback of the surrounding release does not silently misinterpret an external payload it was not built to expect, whether or not the version pin gets reverted in step.
How Long a Broken Rollback Stays Invisible
Illustrative Table 2 — Hypothetical Detection Lag by Failure Mechanism
The figures below are an illustrative, hypothetical scenario constructed to show relative ordering and reasoning, not measured industry data or a benchmark from any published study. No source publishes a cross-industry statistic for "time to detect a broken rollback," so this table should be read as a reasoning tool, not a citation.
| Failure mechanism | Illustrative time until rollback failure becomes visible | Why the lag differs |
|---|---|---|
Schema migration (NOT NULL write failure) |
Minutes | Fails on the very next write; visible almost immediately once traffic resumes |
| Feature flag left in unexpected state | Minutes to hours | Depends on how quickly traffic hits the specific code branch affected by the flag |
| Cache entries in new format | Minutes to hours | Visible on first read of an affected key, but may be masked if cache hit rate on those keys is low |
| Message/event schema mismatch | Hours | Depends on queue depth and consumer processing rate; backlog messages surface the issue gradually |
| Third-party webhook contract drift | Hours to days | Depends entirely on the third party's own release schedule, which is outside the team's visibility |
What this ordering illustrates, even without real measured data behind it, is a genuine engineering insight worth taking seriously: the mechanisms that fail fastest (schema, flags) are also the ones a rollback drill in a staging environment is most likely to catch, because they surface on the very next interaction. The mechanisms that fail slowest (queue backlogs, third-party contracts) are the ones a quick rollback smoke test is least likely to catch, because the failure condition depends on timing and external state that a five-minute staging exercise will not reproduce. This is the direct argument for why rollback testing has to include more than "does the deploy command succeed" — it has to account for the specific compatibility surfaces that fail on different timescales, which is exactly what the checklist later in this article is built to do.
Illustrative Table 3 — Hypothetical Investment Gap Between Forward and Reverse Deployment Testing
This is a reasoning illustration, not a survey result. It is built from the recurring pattern described across the mechanisms above, not from a measured study of engineering organizations.
| Testing practice | Typical investment level in forward deployment (illustrative, 0–5 scale) | Typical investment level in rollback path (illustrative, 0–5 scale) |
|---|---|---|
| Automated test suite coverage | 5 | 1 |
| Staging environment validation | 5 | 1 |
| Canary or phased rollout analysis | 4 | 0 |
| Load and performance testing | 4 | 0 |
| Explicit compatibility check across versions | 2 | 0 |
| Documented, rehearsed procedure | 3 | 1 |
The gap illustrated here is the article's central claim rendered as a comparison: it is not that rollback testing is technically hard to do — most of the individual checks are straightforward — it is that almost no organizational process currently asks for it. Forward-deployment testing earns investment because a broken forward deployment is caught constantly, every day, by the same pipeline that ships it. A broken rollback path is caught only when someone tries to use it under pressure, which is precisely the condition under which a team has the least tolerance for a fresh, unexpected failure.
The Expand/Contract Pattern: Building Migrations That Roll Back on Purpose
The single most effective structural fix for the schema-migration failure mode — and, with minor adaptation, for the message-schema failure mode as well — is a pattern with a long history in the schema-evolution literature, most commonly known as expand/contract, or as Martin Fowler describes the more general version of the same idea, parallel change. Fowler's description frames it as a way to change an interface that many consumers depend on without forcing them all to update simultaneously, breaking the change into three explicit phases: expand the interface to support both the old and new versions at once, migrate consumers to the new version gradually, and contract by removing the old version once every consumer has moved (Martin Fowler, "Parallel Change"). Fowler notes directly that most database refactoring follows this same shape, where the migrate phase is the transition window between the old and new schema, lasting until every piece of code that touches the table has been updated to use the new structure.
Applied specifically to the rollback problem, the pattern reorders a change that would otherwise be a single risky release into a short sequence of releases, each of which is independently safe to roll back:
Phase 1 — Expand. Add the new column as nullable, with no constraint that could reject a write from code that doesn't know about it yet. Deploy this migration on its own, with no accompanying application-code change that depends on the column existing. If this release needs to be rolled back, there is nothing to roll back — the schema addition is inert until code starts using it.
Phase 2 — Migrate. Deploy application code that writes to the new column on every write path, while the column is still nullable and the old column (if this is a rename or restructure rather than a pure addition) is still being written for backward compatibility. This is the phase where, if a rollback is needed, going back to the pre-Phase-2 version is completely safe: that version never touches the new column, and the column being nullable means its absence causes no constraint violation.
Phase 3 — Backfill. Run a background job to populate the new column for existing rows that predate Phase 2. This is a data operation, not a schema change, and should be designed to be safely re-run or paused without corrupting state.
Phase 4 — Contract. Only once every row is populated and every code path has been running Phase 2's dual-write behavior for a full release cycle with no incidents, add the NOT NULL constraint (and drop the old column, if applicable) in its own release. By this point, no version of the application that could plausibly be a rollback target is unaware of the column, because Phase 2 has been live long enough to be the actual rollback target if anything goes wrong.
A concrete migration sequence, expressed as SQL and pseudocode for a typical migration tool, makes the pattern less abstract:
-- Phase 1: Expand — nullable, no constraint, safe on its own
ALTER TABLE transactions
ADD COLUMN settlement_currency VARCHAR(3) NULL;
-- Phase 2 ships as an application-code release alongside no migration at all.
-- The application now writes settlement_currency on every new transaction,
-- but the column remains nullable, so a rollback to pre-Phase-2 code
-- produces rows with a NULL value, which the schema still permits.
-- Phase 3: Backfill — a data operation, run separately from any deploy,
-- idempotent and safely re-runnable if interrupted
UPDATE transactions
SET settlement_currency = 'USD'
WHERE settlement_currency IS NULL
AND created_at < NOW();
-- Phase 4: Contract — only after Phase 2 has been the stable, live
-- version for a full release cycle with the backfill verified complete
ALTER TABLE transactions
ALTER COLUMN settlement_currency SET NOT NULL;
The same logic extends to the message-schema case from earlier: introduce the new field as optional with a sensible default for at least one full deployment cycle before marking it required, so that both the previous and current consumer versions can read anything currently in the queue. This is precisely what Confluent's backward/forward compatibility model is designed to formalize for event schemas, and the underlying discipline is identical to expand/contract even though the tooling is different (Confluent documentation, "Schema Evolution and Compatibility").
It is worth being honest about the cost of this pattern, because that cost is exactly why teams skip it under deadline pressure: expand/contract turns one release into three or four, spread across a longer calendar window, with a backfill job that needs monitoring and a contract phase that has to wait for confidence, not just for code to be ready. For a small, low-traffic table with a short migration window, this overhead can feel disproportionate, and a team may reasonably decide the risk of a single-step migration is acceptable for that specific table. The decision to skip expand/contract should be a deliberate, informed one made by someone who understands what they are trading away — not a default that happens because nobody raised the question.
Testing Rollback as a Release Gate, Not a Hope
Building rollback-safe migrations, as described above, removes one entire category of failure. It does not, by itself, verify that a given release is actually safe to roll back — that verification has to happen as an explicit step, not an assumption, and it belongs in the same place forward-deployment testing already lives: the pipeline.
A staging rollback drill, concretely. The most direct version of this is close to what Google's CRE team describes: after deploying a release candidate to staging, deliberately roll it back and verify the system still functions, before promoting to production. For a Kubernetes-based deployment, this is a short, scriptable sequence:
# Deploy the release candidate to staging
kubectl apply -f deployment-v2.yaml
# Verify it is healthy
kubectl rollout status deployment/orders-service
# Run the staging smoke-test suite against the new version
./run-smoke-tests.sh --target=staging
# Deliberately roll back, exactly as an incident responder would
kubectl rollout undo deployment/orders-service
# Confirm the previous version is running
kubectl rollout status deployment/orders-service
kubectl rollout history deployment/orders-service
# Re-run the SAME smoke-test suite against the rolled-back version —
# this is the step most rollback processes skip entirely
./run-smoke-tests.sh --target=staging
The point of re-running the same smoke-test suite after rollback, rather than a separate lighter check, is that the goal is not to confirm the deployment tool executed a command successfully — kubectl rollout status already confirms that. The goal is to confirm the previous version, running against whatever schema, cache, and queue state the new version already touched during its brief staging run, still behaves correctly. That is the guarantee the earlier example's on-call engineer needed and did not have.
What a rollback drill needs to specifically exercise, beyond a generic smoke test:
- At least one write to any table touched by a migration in the release under test, confirming no constraint violation occurs against data the new version may have already written.
- At least one read of any cache key whose format or contents the new version modifies, confirming the old version can still parse what it finds.
- If the release changes a message or event schema, a queue populated with at least one message in the new format before rollback, confirming the rolled-back consumer can still process it — or is configured to explicitly skip and alert on it rather than silently failing.
- If the release introduces or modifies a feature flag, an explicit check of what the rolled-back code does when it encounters the flag in whatever state the new release left it in.
- A timing check for any queue-based system: how long does the queue backlog take to drain of new-format messages after rollback, and does the rolled-back consumer survive that window without falling over or silently corrupting data.
None of these require exotic tooling. They require someone to decide, as part of release planning, that this list gets checked before a release ships — the same way a team decided, at some point in the past, that a test suite has to pass before a merge. The barrier is organizational, not technical: rollback testing has no natural owner in most engineering organizations, because it does not obviously belong to whoever owns the pipeline (their job is usually defined as "make deployment work," not "make undoing deployment work"), and it is not something product or QA teams naturally think to ask about, because from their perspective a release either works or it doesn't — reversibility is an operational property invisible from the outside until it is needed.
A note on staging fidelity. A rollback drill is only as trustworthy as the environment it runs in. A staging environment with an empty database, no representative cache state, and no realistic queue backlog will pass a rollback drill regardless of whether the production rollback would actually work, because none of the conditions that break rollback — existing rows without a value for a new required column, cache entries in the new format, messages in flight — exist in an empty staging environment. This is the same environment-fidelity problem that undermines plenty of other testing efforts, and it applies here with particular force: a rollback test against a database seeded only with fresh test data will not catch the NOT NULL failure from the opening scenario, because every row already has the column populated by the same version being tested. The staging database needs to include rows that predate the schema change under test — either by running the drill against a snapshot of production-representative data, or by deliberately inserting pre-migration-shaped rows before running the test.
Making the drill part of the pipeline, not a manual exercise someone has to remember. A rollback drill that depends on an engineer remembering to run it manually before every migration-bearing release will, in practice, get skipped under deadline pressure — which is exactly the condition under which it matters most. The more durable version of this treats the drill as an automated gate triggered by the same signal that already identifies a release as migration-bearing: the presence of a schema migration file, a message-schema version bump, or a label applied to the pull request. A simplified pipeline stage expressing this might look like the following:
# CI pipeline stage: rollback-safety-gate
# Triggers only on releases that touch a migration, event schema,
# or are explicitly labeled as rollback-sensitive.
rollback-safety-gate:
stage: pre-release
rules:
- if: $CI_MERGE_REQUEST_LABELS =~ /schema-migration|event-schema|rollback-sensitive/
script:
- kubectl apply -f deployment-candidate.yaml -n staging
- kubectl rollout status deployment/${SERVICE_NAME} -n staging
- ./scripts/seed-staging-with-pre-change-data.sh
- ./run-smoke-tests.sh --target=staging
- kubectl rollout undo deployment/${SERVICE_NAME} -n staging
- kubectl rollout status deployment/${SERVICE_NAME} -n staging
- ./run-smoke-tests.sh --target=staging --context=post-rollback
allow_failure: false
The specific detail worth noting is allow_failure: false paired with a rule that scopes the gate to releases that actually carry rollback risk, rather than running an expensive rollback drill on every merge regardless of relevance. A release that only touches front-end copy or an isolated internal tool does not need this gate; a release that adds a migration, changes an event schema, or is explicitly flagged by its author as rollback-sensitive does. Scoping the gate this way keeps the cost proportional to the risk, which is also the argument most likely to get this adopted by a team wary of slowing down every release for the sake of the few that actually need the extra scrutiny.
When Rollback Is the Wrong Answer: Deciding on Roll-Forward Deliberately
Not every change can be made cleanly reversible, and pretending otherwise produces its own kind of risk: a team that spends disproportionate engineering effort trying to make every migration perfectly rollback-safe, including ones where the business cost of a short delay for a forward fix is trivial, is optimizing the wrong variable. The honest alternative to rollback, in cases where reversibility is either impossible or prohibitively expensive to build, is roll-forward: instead of reverting to the previous version, ship a new, corrected version forward as quickly as possible. This is a legitimate strategy, not a failure to have a rollback plan — but it only works if it is chosen deliberately, ahead of time, with the fix-forward path itself rehearsed and fast, rather than discovered as the only remaining option during an incident because rollback turned out to be broken.
Illustrative Table 4 — Rollback vs. Roll-Forward Decision Matrix
| Situation | Favor rollback | Favor roll-forward |
|---|---|---|
| The bad release included a destructive or irreversible data migration (e.g., dropped column, deleted rows) | Rarely viable — the data the old version needs may no longer exist | Usually the only real option; fix forward and, if needed, restore data separately |
| The failure is isolated to application logic with no schema, queue, or contract change involved | Fast and low-risk if the previous version is known-good | Unnecessary; rollback is simpler and faster here |
| The release is mid-way through an expand/contract migration (Phase 2 or 3) | Safe by design — that is the point of the pattern | Not needed if the pattern was followed correctly |
| The failure involves a message/event schema already partially consumed by other services | Risky — other services may already depend on the new shape | Often safer to fix forward and coordinate schema correction across consumers |
| A third-party contract changed independently and the current code no longer matches it | Rollback does not address the root cause — the old code may match it even less | Fix-forward is usually required, since the mismatch is external, not something rollback can undo |
| The team has a fast, well-tested continuous deployment pipeline and a small, well-understood fix | Rollback buys time but does not fix the underlying issue | If the fix is small and confidently testable, rolling forward can be faster than a rollback-then-redeploy cycle |
| Confidence in what "rollback" actually restores is low (untested, unclear what state it leaves the system in) | Do not choose rollback by default here — verify first | Roll-forward is the safer default when rollback itself is unverified |
The last row is the one this article is most concerned with, because it is the situation most teams are actually in without realizing it: rollback is chosen by default, as a reflex, precisely because nobody has verified whether it is the safer option for a given release. A team that has done the work described in the previous sections — expand/contract migrations, a rollback drill in the pipeline, defensive parsing on message and webhook consumers — can make this decision quickly and confidently during an incident, because they already know which of these rows applies. A team that has not done that work is making the decision blind, under time pressure, with the added risk that the choice they default to (rollback, because it feels like the safe, conservative option) may be the one most likely to make things worse.
A Rollback-Readiness Checklist for the Next Release
The following checklist is built specifically for this article's argument — a compact, pre-release gate a release owner or platform engineer can run through before signing off on a deployment, rather than a generic release checklist repurposed with new labels.
- Does this release include a schema migration? If yes, does it follow the expand/contract sequence, or is the risk of a single-step migration being accepted deliberately, by name, by someone accountable for that decision?
- Does this release introduce or modify a feature flag? If yes, is there an explicit note in the release plan for what happens to that flag's state if only the code is rolled back, and who is responsible for reverting the flag if needed?
- Does this release change a message or event schema consumed by another service? If yes, is the new field optional with a safe default for at least one full release cycle, and has the consuming service's current version been confirmed to tolerate both the old and new shapes?
- Does this release change how a cache value is written or serialized? If yes, is there a key-namespace version bump, or an explicit cache-flush step written into the rollback runbook?
- Does this release depend on a third-party API or webhook contract? If yes, does the consuming code degrade gracefully on an unrecognized field or missing optional field, rather than failing hard?
- Has this release been deliberately rolled back in staging, against data that predates the change, with the same smoke-test suite re-run afterward?
- If rollback is not realistic for this release, has that been decided explicitly, with a fix-forward plan and a named owner, rather than left undiscovered until an incident?
- Is the revision history retention (Kubernetes
revisionHistoryLimit, or the equivalent in whatever deployment tool is in use) long enough that the actual known-good version is still available to roll back to, not just "some" previous version?
A release that can answer all eight questions with a clear, specific answer — even when that answer is "we accepted this risk deliberately" — is in a fundamentally different position during an incident than a release where nobody has asked them.
Ownership: Whose Job Is This, at Different Company Stages
Rollback testing fails to happen in most organizations not because anyone disagrees it matters, but because it sits in a gap between roles, and closing that gap looks different depending on company size.
At an early-stage startup, formal ownership of this is usually unrealistic and mostly unnecessary — a small team with a small system can often reason about rollback safety informally, because the same two or three engineers who wrote the migration are also the ones who would respond to an incident and already understand the schema by memory. The risk at this stage is not lack of ownership; it is lack of the habit. The cheapest fix is procedural: add the rollback-readiness checklist above as a required step in the pull request template for any change touching schema, flags, queues, caches, or third-party integrations, so the questions get asked even when there is no dedicated release engineer to ask them.
At a scale-up, the team writing a migration and the team that would respond to a 2 a.m. incident involving it are frequently no longer the same people, and the informal-memory approach that worked earlier stops being reliable. This is the stage where rollback testing benefits most from an explicit home — usually within a platform or release engineering function, if one exists, or as an explicit responsibility assigned to whoever owns the CI/CD pipeline if it does not. The specific deliverable at this stage is turning the staging rollback drill from an occasional manual exercise into an automated pipeline gate that runs on every release touching a migration, a message schema, or a third-party contract, with a clear escalation path when it fails.
At an enterprise scale, with many services, many teams, and often many independently deployable components sharing common infrastructure, the hardest version of this problem appears: a rollback of one service can leave it out of compatibility with other services that did not roll back, exactly as Google's CRE article describes with its recommendation to assume any dependency could be rolled back by one version and to design accordingly (Google Cloud Blog, "SRE at Google: Reliable releases and rollbacks"). At this scale, rollback safety cannot be verified service-by-service in isolation; it requires an organization-wide compatibility convention — for example, a rule that no service may depend on a contract change in another service until that change has been live and stable for at least one full release cycle elsewhere — enforced through architecture review or automated contract testing rather than through individual engineers remembering to check.
Illustrative Table 5 — A Responsibility Map for Rollback Safety
Ownership gaps are rarely the result of anyone refusing responsibility; they are the result of nobody having explicitly claimed a piece of work that falls between two roles. The table below assigns each rollback-relevant decision to a role, built specifically for this article's argument rather than repurposed from a generic RACI template.
| Decision or action | Primary owner | Consulted | Informed |
|---|---|---|---|
| Deciding whether a migration needs expand/contract sequencing | Engineer authoring the migration | Platform/release engineering lead | Team lead, on-call rotation |
| Approving a single-step migration as an accepted risk | Team lead or engineering manager | Platform/release engineering lead | On-call rotation |
| Running the staging rollback drill before release | Platform/release engineering (automated gate) or release owner | Author of the change | Whole team, via pipeline status |
| Reverting a feature flag introduced by a rolled-back release | On-call engineer, at the moment of rollback | Product owner of the flag | Team lead |
| Maintaining revision history retention settings | Platform engineering | — | All service owners |
| Verifying third-party contract version pins after a rollback | On-call engineer | Integration owner | Vendor account owner, if applicable |
| Deciding rollback vs. roll-forward during an incident | Incident commander / on-call lead | Whoever authored the release | Engineering leadership, post-incident |
The row most often left blank in practice is the fourth one — reverting a flag introduced by a rolled-back release — precisely because flag state usually lives in a system owned by whoever built the feature, not by whoever responds to incidents. Naming it explicitly, even informally, closes a gap that otherwise only gets discovered the first time someone needs it closed.
Warning Signs a Rollback Path Is Already Broken
Some of these are detectable without ever running a deliberate drill, simply by paying attention to patterns that already exist in a team's history and tooling. None of them proves a rollback is broken on their own, but each one is a reasonable basis for prioritizing a drill sooner rather than later.
- Nobody can say when the service was last actually rolled back. If the honest answer is "not in recent memory," the rollback path has not been exercised recently enough for anyone to have confidence in it, regardless of how it looked the last time it was used.
- Revision history retention is set to a default value nobody has reviewed. A
revisionHistoryLimitof ten sounds generous until a team ships several releases a day and discovers, mid-incident, that the version they actually need has already aged out. - Migrations and application code changes routinely ship in the same release, by habit rather than decision. This is the single strongest predictor of the
NOT NULL-style failure described earlier, because it means the two changes were never separated into independently reversible steps in the first place. - Feature flag cleanup is chronically behind, and nobody owns reverting a flag as part of an incident response. A backlog of unreverted or forgotten flags is also a backlog of undocumented assumptions about what "the previous version" of the system actually means.
- Message consumers throw hard errors on unrecognized fields rather than ignoring them. This is a strict, brittle parsing posture that will surface every schema evolution as a potential incident, in both directions — forward and backward.
- Nobody has ever asked, out loud, in a release review, "is this safe to roll back?" The absence of the question is itself the clearest signal that the answer has never been verified.
Questions Engineering Leaders Should Ask Before the Next Incident
- When was the last time we actually rolled back a production service, deliberately, to verify it still works — not during a real incident, but as a rehearsal?
- For the last five releases that included a schema migration, was that migration structured so a rollback to the prior version was safe, or did it depend on the schema staying exactly where the new release left it?
- If we had to roll back right now, do we know how far back our retained revision history actually goes, or are we assuming it covers more than it does?
- Which of our message consumers would silently misread a message rather than erroring loudly if the producer's schema changed underneath them?
- Do we have any releases in flight right now where rollback is not actually realistic, and if so, does everyone who might need to respond to an incident involving them know that in advance?
- Who, specifically, owns the answer to "is this release safe to roll back" — and if the honest answer is "no one," what is the smallest first step toward changing that?
Frequently Asked Questions
Is rolling back a deployment always faster than fixing forward? Not necessarily. A rollback command can execute quickly, but if the release included changes that a rollback cannot cleanly reverse — a destructive migration, a partially-consumed message schema change, a third-party contract shift — the rollback itself can introduce a second incident on top of the first. Speed of execution and speed of actually restoring a working system are different measurements, and only the second one matters.
Does using a blue/green or canary deployment strategy make rollback automatically safe? No. Both strategies make it fast and low-risk to redirect traffic back to the previous version at the routing or load-balancer layer, but neither one addresses whether the database, cache, message queue, or third-party contracts underneath both versions are still compatible with the version traffic is being redirected to. Rollback speed and rollback correctness are separate properties.
What is the simplest first step for a team that has never tested rollback at all? Pick the next release that includes a schema migration, and before it ships, deliberately deploy it to staging, let it run briefly, then roll it back and re-run the same test suite against the rolled-back version, using staging data that predates the migration rather than fresh test data. That single exercise, repeated as a habit, catches the most common and most damaging failure mode described in this article.
Is the expand/contract pattern necessary for every schema change? No. It adds real overhead — more releases, a longer calendar window, a backfill job to monitor — and for low-risk, low-traffic tables or additive changes with no constraint that could reject an old write, a single-step migration may be a reasonable, deliberately accepted risk. The pattern earns its cost on changes involving new required fields, constraint additions, or column removals on tables an old code version would still need to write to correctly.
How does this relate to feature flag testing generally? This article addresses one specific rollback-related risk from feature flags: a flag introduced or modified as part of a release being left in a state the rolled-back code was never tested against. The broader questions of flag estate management, combinatorial flag-state testing, and flag lifecycle policy are a distinct and larger subject in their own right, addressed separately.
Should every team use expand/contract and full rollback testing for every release? No. The right level of investment depends on the blast radius of getting it wrong. A release touching a core system of record, a widely-consumed event schema, or a revenue-critical path warrants the full discipline described here. A low-traffic internal tool with an easy manual recovery path may reasonably accept more risk. The mistake is not under-investing in every case — it is not making that trade-off deliberately at all.
Where This Fits in a Release Strategy
Verifying that a deployment can actually be undone is not a separate discipline from the testing an engineering organization already does — it is the same rigor applied to a path that usually gets skipped because it is invoked rarely and trusted by default. QAtronic works with engineering and platform teams to build rollback verification into existing CI/CD pipelines: identifying which releases carry real reversibility risk, designing expand/contract migration sequences for schema changes that need them, and building the staging rollback drills and compatibility checks described in this article into a release gate rather than leaving them as an assumption. The goal is not to add process for its own sake — it is to make sure the answer to "can we roll this back" is something the team already knows, rather than something they find out during the incident that requires it.
The Undo Button You Actually Have
The team in the opening scenario did not lack discipline. They lacked a test for a specific claim they had been making, implicitly, every time they told themselves a bad release could be safely reversed. That claim — "we can roll back if something goes wrong" — is not a fact about the deployment tool. It is a fact about the schema, the flags, the queues, the caches, and the contracts a specific release touches, and it is either true or false for each release independently, whether or not anyone checks.
The practical distinction this article has argued for is narrow and specific: rollback is not a property a deployment platform gives you. It is a property an engineering team builds, release by release, through migration sequencing, message-schema discipline, and a habit of deliberately testing the reverse path before trusting it. A team that has done that work can make a fast, confident decision between rollback and roll-forward during an incident, because they already know which one actually works for the release in question. A team that has not done that work is making the same decision blind, under the worst possible time pressure, with a coin flip dressed up as a safety net.
The question worth taking back to an engineering team is not whether the deployment pipeline is good — plenty of teams have genuinely excellent forward-deployment pipelines and no functioning answer to the rollback question at all. The question is narrower and more uncomfortable: for the next release on the calendar, does anyone actually know whether rolling it back would work, or is everyone assuming someone else already checked?