A rollback plan that a team can actually rehearse has eight sections. Write them in this order; each one depends on the one before.
- Header: migration name, ticket, the exact commit hashes of the migration and of the application version that expects it.
- Forward steps: numbered, each with the SQL or command.
- Reverse steps: numbered to match the forward steps, each with the SQL or command.
- Irreversible data: a table of what gets destroyed, when, and where the recovery copy is.
- Point of no return: the numbered step after which rollback is not possible, and why.
- Validation queries: the statements that prove success or failure at each checkpoint.
- Owners and decision authority: who runs each command, who calls the stop, who approves a restore.
- Communication: who is told what, on which channel, at which timestamp.
The eighth item is easy to skip and the most useful under pressure. A rollback is a business event as much as a technical one, and the difference between a rehearsed recovery and an improvised one can be measured in downtime (AWS Migration blog).
Reverse steps with the exact command
Do not write "remove the column." Write the statement. For an expand step that adds a nullable column:
-- forward
ALTER TABLE users ADD COLUMN email_address text;
-- reverse
ALTER TABLE users DROP COLUMN IF EXISTS email_address;
Then write the check that the reverse actually took effect:
SELECT count(*) FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'email_address';
-- expect 0
For a backfill, the reverse is not a statement but a stop condition. The plan should say how the backfill job is identified and terminated, for example by job ID in the scheduler, and what happens to rows already copied. Usually the answer is "leave them": a nullable column with extra data hurts nothing, and dropping it is the later reverse. The expand and contract write-up is explicit that during transition both structures coexist and data may be duplicated, often with triggers, so the plan needs an explicit statement on what happens to that duplicate data on the way back (Jasmin Fluri).
For a constraint change, the reverse is another constraint statement. If the forward step sets a column NOT NULL, the reverse is ALTER TABLE users ALTER COLUMN email_address DROP NOT NULL, which is cheap. The expensive part is getting to NOT NULL safely in the first place: adding the constraint directly takes an ACCESS EXCLUSIVE lock, which blocks reads as well as writes, while the database scans every row (Atlas). The safe forward path is backfill, add a NOT VALID check constraint, validate it, then promote, and the plan should spell out which of those sub-steps is reversible and which is not (RunxBuild).
For index changes, the reversibility depends on how the index was built. A plain CREATE INDEX takes a SHARE lock, which allows concurrent reads but blocks all writes for the duration of the build, while CREATE INDEX CONCURRENTLY takes a weaker lock and lets writes continue at the cost of a longer build (Bytebase). Both are reversed by DROP INDEX, but if a concurrent build fails it leaves an invalid index behind that must be dropped by hand, so the plan should include the query that finds invalid indexes in that case (Stack Overflow). The CONCURRENTLY keyword is a build instruction, not a stored property of the index, which is why a completed index looks identical either way (PostgreSQL docs).
The data that cannot be un-dropped
This is the section people read when something has gone wrong, so make it brutal. For every destructive action, record four things: what is destroyed, the step number that destroys it, whether a copy exists outside the affected table, and how long that copy survives. A column drop, a table rename followed by a drop, a type change that truncates, and a backfill that overwrites a column in place all belong here.
A worked example makes the cost concrete. Suppose a backfill copies users.name into new first_name and last_name columns by splitting on the first space. Then a later migration drops users.name. If the split is wrong for compound surnames, the original is gone from the table, and the reverse is not "run the backfill backwards" because the split cannot be inverted. The only recovery is a backup, and its value decays by the minute. An article on rollback strategy puts it plainly: a backup from fifteen minutes ago is far more useful than one from twenty-four hours ago, and restoring it sacrifices everything written since (Octopus).
So the plan states, in the irreversible data table, something like: "users.name dropped at step 9. Full table snapshot taken at step 3 into users_name_backup. Snapshot retained seven days. Retention confirmed by backup job owner." That sentence tells a future reader exactly what to ask for and whose answer to trust.
Data migration rollback planning goes further and separates the four kinds of state that must recover together: schema, data, configuration and application version, plus a check that the recovered versions actually work together (softwaremodernizationservices.com). A backup proves you have a recovery point. It does not prove the old application can read the restored schema, so include the application version in the plan and rehearse the pair.
Marking the point of no return
The point of no return is the first step after which the reverse steps no longer restore the system. It is almost never the first destructive statement; it is the commit, deploy or backfill that makes the destruction visible to users. Common markers:
- The merge of the contract migration that drops the old column, once it has run in production.
- The cutover of reads from the old column to the new one, once the old column stops receiving writes and the backfill has stopped.
- The first write to a new table by the live application, after which the old table is stale.
- The promotion of a NOT NULL constraint, after which old application code that omits the column fails.
Write the marker as a numbered step, with a one-line reason, like "Step 7, first application write to orders_v2. Before this, rollback is a code deploy plus a drop. After it, rollback requires replaying orders from the v2 table back into v1." That sentence is what the on-call engineer reads at 02:00 when deciding whether to roll back or fix forward. It is also the sentence that should have been argued about in design review, because the point of no return is a design choice, not an accident.
The cutover literature recommends that the decision to roll back or fix forward be assigned to a named person and recorded alongside the boundary itself (softwaremodernizationservices.com). The Octopus article makes the same point from the deployment side: sometimes rolling forward is less risky than rolling back, and someone must weigh which risk is larger (Octopus). Put that person's name and backup name in the document.