Introduction
Rolling out a schema change is the easy half of the migration story. The harder half, and the one most teams under-plan for, is what happens when that rollout goes wrong and someone has to decide whether to roll back. In application code, rollback is nearly free: redeploy the previous artifact and traffic starts hitting the old code path again. In a database, rollback is a different kind of operation entirely, because by the time anyone notices a problem, the new schema may already have real production writes sitting in it. Rolling back the schema without rolling back the data is not a rollback at all; it is a second migration disguised as one, and it is often riskier than the change that prompted it.
This article focuses specifically on that scenario: a schema has been rolled out, the application is live against it, and now a subset of rows or an entire table has data shaped by the new schema. We will walk through how safe rollout should be structured so this situation is survivable, what a genuine schema rollback looks like at the SQL level, and the much harder problem of reconciling or discarding new-schema data when a rollback is unavoidable. The goal is a concrete decision framework, not a single silver-bullet technique, because the right answer depends heavily on how much data has already been written and how compatible the old and new shapes are.
Context: Why "Rollback" Means Something Different for a Database
When engineers talk about rolling back a deployment, they usually mean reverting to a previous, known-good binary or container image. That operation is stateless: the artifact from five minutes ago behaves identically to the artifact running now, because it holds no memory of what happened in between. A database rollback cannot make that same guarantee, because the database is exactly the thing that remembers what happened in between. If ten thousand orders were written with a new status enum value that the old application code has never heard of, reverting the application will not un-write those orders; it will just make the old code start choking on rows it cannot parse.
This is why "rollback" in a schema-migration context needs to be split into at least two distinct operations that are easy to conflate. The first is a structural rollback: reversing the DDL, dropping a column that was added, restoring a column that was dropped, or reverting a type change. The second is a data rollback: reconciling or discarding the rows that were written or modified while the new schema was live, so that the data matches what the old schema and old application code expect. Teams that only think about the first kind of rollback are the ones who get paged at 2 a.m. because the schema reverted cleanly but the application is now throwing exceptions on live traffic, because real rows do not match what old code assumes.
The severity of this problem scales directly with two variables: how long the new schema was live in production, and how central the affected table is to write traffic. A schema change to a rarely-written configuration table that was live for thirty seconds before someone caught a problem is a very different rollback than a schema change to an orders or payments table that took writes for six hours before anyone noticed. In the second case, a naive rollback is not a mitigation; it is itself an incident, because reverting the schema underneath data that no longer fits it can silently corrupt or orphan records, or crash the old code path entirely depending on how defensively it was written.
The Domain Language of Rollback
Just as expand-contract gave migrations a shared vocabulary for rollout, a smaller but equally important vocabulary exists for rollback and recovery, and it is worth being precise about it before writing any code.
A down-migration is the DDL script intended to reverse a specific up-migration; it is a structural rollback mechanism only, and by itself it says nothing about what happens to data written under the new schema. A compatibility window is the period during a rollout when both old and new application code, and by extension both old and new data shapes, must coexist safely; a well-designed expand-contract rollout keeps this window wide and forgiving specifically so that a rollback inside that window is cheap. A point-of-no-return is the moment after which rollback stops being a schema-level operation and becomes a data reconciliation project, typically the moment the old structure is dropped in the contract phase, or the moment enough new-shape data has accumulated that converting it back would itself be lossy. Teams sometimes call the practice of designing migrations specifically to keep this point as late as possible rollback-safe rollout, and it is the single biggest lever available for making rollback decisions boring instead of terrifying.
Designing a Rollout That Can Actually Be Rolled Back
The best rollback strategy is one that is designed into the rollout itself, before anything is deployed to production. This means treating the expand phase not just as a mechanism for zero-downtime deploys, but as an insurance policy: as long as the old structure still exists and is still being kept in sync, rollback remains a schema-only operation, because the old shape of the data is still present and current.
Consider a migration that changes an orders.status column from a free-text VARCHAR to a constrained ENUM-like CHECK constraint with a fixed set of values. A rollout designed for rollback safety adds the new column alongside the old one, rather than converting in place:
-- Expand: add the new, constrained column without touching the old one
ALTER TABLE orders ADD COLUMN status_v2 TEXT
CHECK (status_v2 IN ('pending', 'paid', 'shipped', 'cancelled'));
Application code is then deployed to write both status and status_v2 on every write, and to read from status until the team is confident in the new column. This dual-write period is exactly the compatibility window described above, and its entire purpose is to make sure that, at any point during it, the application can be reverted to reading status again with zero data loss, because status was never touched.
def update_order_status(conn, order_id: int, new_status: str, legacy_status: str) -> None:
"""
Dual-write during the compatibility window: legacy_status maps
new_status back into the pre-migration vocabulary so a rollback
of the read path requires no data conversion.
"""
conn.execute(
text("""
UPDATE orders
SET status = :legacy_status,
status_v2 = :new_status,
updated_at = now()
WHERE id = :order_id
"""),
{"legacy_status": legacy_status, "new_status": new_status, "order_id": order_id},
)
Only once the new column has been the source of truth for reads for a safe observation period, with monitoring confirming no divergence between old and new values, does the team consider the migration far enough along that the old column can be dropped. Crucially, dropping the old column is the point-of-no-return: it is the last moment where rollback is guaranteed to be a pure schema operation rather than a data reconciliation problem, and many teams treat it as a deliberate, separately reviewed step rather than something bundled into the same release that introduced the new column.
A second, complementary technique is running the contract phase far later than seems necessary. It is tempting to clean up the old column as soon as the new one looks correct, but the cost of leaving an unused column in place for a few extra weeks is close to zero, while the cost of needing it back after it has been dropped can be substantial. Teams operating conservatively often gate the contract migration behind an explicit checklist: confirmed zero reads of the old column in application logs or query telemetry, a defined minimum soak time, and sign-off from whoever owns the affected service, rather than a fixed calendar date.
When Rollback Means Reconciling Data, Not Just Schema
Even a well-designed rollout can end up needing a rollback after the point-of-no-return, or facing a rollout that was not designed this carefully in the first place. This is the scenario the introduction promised to address directly: the new schema is live, the old structure is gone or was never kept in sync, and real rows now exist that the old schema and old application code cannot represent.
The first step is always the same regardless of technique: freeze new writes to the affected path, typically via a feature flag or a maintenance-mode toggle on the specific write endpoint, so the amount of data needing reconciliation stops growing while a plan is made. Attempting to reconcile data while the application is still actively writing new-schema rows is chasing a moving target, and it is how rollback attempts turn into extended outages.
Once writes are frozen, the reconciliation strategy depends on whether the new schema is a strict superset of the old one, a lossy transformation of it, or something structurally incompatible. If the new schema strictly added information without discarding anything, for example an orders.status_v2 ENUM column that a straightforward mapping can always convert back to the old free-text values, then reconciliation is a scripted backward transformation, conceptually the mirror image of the forward backfill used during rollout:
STATUS_V2_TO_LEGACY = {
"pending": "pending",
"paid": "paid",
"shipped": "shipped",
"cancelled": "cancelled",
}
def reconcile_status_column(engine, since_timestamp) -> int:
"""
Rebuilds the legacy `status` column from `status_v2` for any row
written or updated after the new schema went live, so old application
code can be safely redeployed against this table.
"""
updated = 0
with engine.connect() as conn:
rows = conn.execute(
text("""
SELECT id, status_v2 FROM orders
WHERE updated_at >= :since AND status_v2 IS NOT NULL
"""),
{"since": since_timestamp},
).fetchall()
for row in rows:
legacy_value = STATUS_V2_TO_LEGACY.get(row.status_v2)
if legacy_value is None:
# Unmapped value: do not guess, flag for manual review instead
continue
conn.execute(
text("UPDATE orders SET status = :legacy_value WHERE id = :id"),
{"legacy_value": legacy_value, "id": row.id},
)
updated += 1
conn.commit()
return updated
If, on the other hand, the new schema discarded information the old schema needs, for example splitting a single address text field into structured street, city, and postal_code columns and dropping the original, reconciliation is lossy by construction: reassembling a single free-text address from structured parts can produce a value that is valid but different from what a user originally typed, and the team has to explicitly decide whether that is acceptable or whether the rollback needs to fall back to a point-in-time backup instead. This is precisely why experienced teams try hard never to drop a column carrying original user input until they are certain it will never be needed, and why, when the transformation truly is irreversible, restoring from backup or point-in-time recovery becomes the only honest option, accepting the data loss that entails for writes since the backup.
A third case, structurally incompatible schemas, arises when a migration did more than reshape a column, for instance splitting one table into two related tables, or merging two tables into one. Here reconciliation is not a column-level script but a genuine data migration project in its own right, usually requiring the same batching, monitoring, and staged rollout discipline as the original forward migration, just run in reverse. Teams in this situation often conclude that "rolling back" is the wrong mental model entirely, and that rolling forward with a corrective migration, one that fixes the specific defect discovered in production rather than reverting the whole change, is faster and safer than attempting a true reversal.
Trade-offs and Pitfalls
The rollback-safe rollout pattern described above is not free. Keeping two representations of the same data in sync during a long compatibility window means more application code paths to maintain, more surface area for the dual-write logic itself to have bugs, and a real, if small, storage and write-amplification cost from carrying redundant columns for weeks at a time. Teams under pressure to "just finish the migration" sometimes shorten the soak period specifically because the ongoing dual-write complexity feels like more work than the contract step, which is a reasonable trade-off for low-traffic tables but a genuinely risky one for tables with data that is hard to reconstruct.
There is also a real risk in over-trusting automated reconciliation scripts of the kind shown above. A backward mapping function is only as good as its coverage of the forward transformation's edge cases, and any row whose new-schema value falls outside the expected mapping needs to fail loudly and be queued for manual review rather than being silently coerced into a default or dropped. A reconciliation script that swallows unmapped values to keep running is, in effect, quietly discarding production data during an incident, which is precisely the outcome the rollback was meant to prevent.
Best Practices
Design every migration that touches a table under live write traffic with rollback in mind from the start, not as an afterthought once something has already gone wrong. Concretely, this means defaulting to the expand phase adding new structure rather than converting columns in place, so that the pre-migration shape of the data survives as long as possible.
Treat the contract phase, the point where old structure is finally dropped, as a distinct, deliberately reviewed step with its own checklist rather than bundling it into the same release that introduced the new structure. A minimum soak time, confirmation from query telemetry that nothing still reads the old column, and explicit sign-off are cheap insurance against needing to reconstruct data that no longer exists anywhere.
Build and test the backward reconciliation path before you need it, not during an incident. If a forward migration includes a backfill script converting old data into the new shape, write and dry-run its mirror image, a script converting new-shape data back to the old shape, at the same time, even if you never expect to use it. An untested reconciliation script written under incident pressure is exactly as risky as an untested down-migration, and for the same reasons.
Finally, make the freeze-writes step an explicit, rehearsed part of any rollback runbook. Deciding, in the middle of an incident, how to stop new writes to a specific table or endpoint is far riskier than having a feature flag or circuit breaker already wired in and tested beforehand. The single most common way rollback attempts turn into extended outages is reconciling data while the system is still actively writing new rows that need reconciling.
Key Takeaways
- Separate "structural rollback" (reversing DDL) from "data rollback" (reconciling rows written under the new schema); they are different problems with different tools.
- Design rollout with expand-contract so the old data shape survives as long as possible, keeping rollback a schema-only operation for as long as possible.
- Treat dropping the old structure (contract) as the point-of-no-return, and gate it behind a deliberate checklist rather than a fixed schedule.
- Before rolling back schema-only, freeze writes to the affected table or endpoint so the amount of data needing reconciliation stops growing.
- Write and test the backward reconciliation script alongside the forward backfill script, not after an incident has already started.
Conclusion
Rolling out a schema change safely and rolling one back safely are really the same discipline viewed from opposite directions. A rollout designed with expand-contract in mind keeps the old data shape alive and in sync for as long as the team needs the safety net, and that same design decision is what turns a potential data-reconciliation crisis into a boring, reversible schema change. The hard case, rolling back after real production data has already been written under a new and incompatible shape, cannot always be made painless, but it can be made survivable by deciding in advance how reconciliation will work, rather than discovering the answer live during an incident.
The uncomfortable truth underneath all of this is that a database rollback is never truly symmetric with a rollout, because a rollout only has to handle data moving forward in time, while a rollback has to account for everything that happened while the new schema was in charge. Respecting that asymmetry, by keeping old structures around longer than feels necessary, by testing reconciliation paths before they are needed, and by treating the moment data becomes irreversible as a deliberate checkpoint rather than an afterthought, is what separates a rollback that is a non-event from one that becomes the incident itself.
References
- Fowler, M. "Evolutionary Database Design." martinfowler.com. https://martinfowler.com/articles/evodb.html
- Ambler, S. W., & Sadalage, P. J. Refactoring Databases: Evolutionary Database Design. Addison-Wesley, 2006.
- PostgreSQL Documentation. "DDL: Transactional DDL" and "ALTER TABLE." https://www.postgresql.org/docs/current/ddl.html
- PostgreSQL Documentation. "Point-in-Time Recovery (PITR)." https://www.postgresql.org/docs/current/continuous-archiving.html
- PostgreSQL Documentation. "Logical Replication." https://www.postgresql.org/docs/current/logical-replication.html
- Google. Site Reliability Engineering, Chapter 8: "Release Engineering" and Chapter 22: "Addressing Cascading Failures." https://sre.google/sre-book/table-of-contents/
- Flyway Documentation. "Undo Migrations." https://documentation.red-gate.com/flyway
- Liquibase Documentation. "Rollback." https://docs.liquibase.com/commands/rollback/rollback.html
- Ruby on Rails Guides. "Active Record Migrations." https://guides.rubyonrails.org/active_record_migrations.html
- Django Documentation. "Migrations." https://docs.djangoproject.com/en/stable/topics/migrations/
- AWS Documentation. "Blue/Green Deployments for Amazon RDS." https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/blue-green-deployments.html
- Percona. "pt-online-schema-change." https://docs.percona.com/percona-toolkit/pt-online-schema-change.html