Database migration rollback in Python: dblift undo

Roll back SQL migrations in Python with undo files: see which versions are undoable, preview the exact SQL with a dry run, then undo to a target version.

Sooner or later a migration reaches a database and you want it gone. Maybe the index slowed down writes, or the table was the wrong shape. "Roll it back" sounds simple. The hard part is that nobody wants a tool guessing how to reverse their DDL.

DBLift doesn't guess. It is Flyway-style raw-SQL migrations for Python teams: no JVM, and you see the exact SQL before it runs. A rollback is just another SQL file you write and review: an undo file. dblift undo runs those files newest first, shows you the plan before it touches anything, and records every reversal in the schema history.

Everything below was run with DBLift 4.10.0 (open source, no licence) against PostgreSQL 17 on 4 October 2026. It uses the same shop schema as adopting an existing database with baseline, this time on an empty development database. Terminal excerpts drop the log banner lines but are otherwise unedited.

Step 1: Write the undo file next to the migration

An undo file takes the same version as the migration it reverses, with a U prefix instead of V. The description after the double underscore is free text, so describe the reversal rather than repeat the original name. DBLift pairs the files by version.

U1_1_0__drop_orders_table.sql
U1_2_0__drop_orders_placed_at_index.sql
V1_0_0__create_customers_table.sql
V1_1_0__create_orders_table.sql
V1_2_0__index_orders_placed_at.sql

Each undo file is one statement:

DROP TABLE orders;
DROP INDEX orders_placed_at_idx;

There is no U1_0_0 on purpose. Dropping the customers table is not something you want one command away. Write undo files where reversal is safe and leave them out where it isn't. Step 6 shows what happens when you ask for an undo that has no file.

Step 2: Check which migrations can be undone

Apply the three migrations, then ask DBLift what it sees.

dblift migrate
dblift info
Found 3 pending migration(s)
Migration lock acquired successfully
Statement executed successfully 
Migration V1_0_0__create_customers_table.sql executed successfully in 13ms
Successfully applied migration V1_0_0__create_customers_table.sql
Migration V1_1_0__create_orders_table.sql executed successfully in 3ms
Successfully applied migration V1_1_0__create_orders_table.sql
Migration V1_2_0__index_orders_placed_at.sql executed successfully in 3ms
Successfully applied migration V1_2_0__index_orders_placed_at.sql
...
╭────────────┬─────────┬────────────────────────┬──────┬─────────────────────┬──────────────┬─────────┬───────────┬──────────╮
│ Category   │ Version │ Description            │ Type │ Installed On        │ Installed By │ State   │ Exec Time │ Undoable │
├────────────┼─────────┼────────────────────────┼──────┼─────────────────────┼──────────────┼─────────┼───────────┼──────────┤
│ Versioned  │ 1.0.0   │ create_customers_table │ SQL  │ 2026-10-04 19:22:06 │ dblift       │ Success │      13ms │    No    │
│ Versioned  │ 1.1.0   │ create_orders_table    │ SQL  │ 2026-10-04 19:22:06 │ dblift       │ Success │       3ms │   Yes    │
│ Versioned  │ 1.2.0   │ index_orders_placed_at │ SQL  │ 2026-10-04 19:22:06 │ dblift       │ Success │       3ms │   Yes    │
╰────────────┴─────────┴────────────────────────┴──────┴─────────────────────┴──────────────┴─────────┴───────────┴──────────╯

The last column is the one to read before any rollback. Undoable is Yes where a matching U file exists and No where it doesn't. If a version you expect to reverse shows No, fix that before you need it, not during an incident.

Step 3: Preview the rollback with a dry run

--target-version names the version you want to end up at, not the one to remove. To get back to just the customers table, the target is 1.0.0. Add --dry-run --show-sql to see the plan and the exact statements without executing anything.

dblift undo --dry-run --show-sql --target-version=1.0.0
Found 2 migration(s) to undo
DRY RUN: Would undo the following migrations:
  - V1_2_0__index_orders_placed_at.sql
  - V1_1_0__create_orders_table.sql
...
SQL Statements:
--------------------------------------------------------------------------------
-- U1_2_0__drop_orders_placed_at_index.sql
DROP INDEX orders_placed_at_idx;
-- U1_1_0__drop_orders_table.sql
DROP TABLE orders;
--------------------------------------------------------------------------------

Two migrations, newest first, and the SQL that will run for each. This is the output to paste into a pull request or an incident channel before anyone runs the real thing.

Step 4: Run the undo

Same command, without --dry-run:

dblift undo --target-version=1.0.0
Found 2 migration(s) to undo
Statement executed successfully 
Migration U1_2_0__drop_orders_placed_at_index.sql executed successfully in 10ms
Successfully undone migration V1_2_0__index_orders_placed_at.sql
Migration U1_1_0__drop_orders_table.sql executed successfully in 3ms
Successfully undone migration V1_1_0__create_orders_table.sql
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SUCCESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Command UNDO completed successfully (Execution time: 41 ms)                  ┃
┃ Schema Version: 1.0.0                                                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Step 5: Read the history

Undo doesn't erase anything from the history. It adds to it.

=== Migration Summary ===
Total Migrations: 5
Applied Migrations: 1
Pending Migrations: 2
Failed Migrations: 0
=========================

╭────────────┬─────────┬────────────────────────┬──────┬─────────────────────┬──────────────┬─────────┬───────────┬──────────╮
│ Category   │ Version │ Description            │ Type │ Installed On        │ Installed By │ State   │ Exec Time │ Undoable │
├────────────┼─────────┼────────────────────────┼──────┼─────────────────────┼──────────────┼─────────┼───────────┼──────────┤
│ Versioned  │ 1.0.0   │ create_customers_table │ SQL  │ 2026-10-04 19:22:06 │ dblift       │ Success │      13ms │    No    │
│ Versioned  │ 1.1.0   │ create_orders_table    │ SQL  │ 2026-10-04 19:22:06 │ dblift       │ Undone  │       3ms │   Yes    │
│ Versioned  │ 1.2.0   │ index_orders_placed_at │ SQL  │ 2026-10-04 19:22:06 │ dblift       │ Undone  │       3ms │   Yes    │
│ Versioned  │ 1.1.0   │ create_orders_table    │ SQL  │                     │              │ Pending │           │   Yes    │
│ Versioned  │ 1.2.0   │ index_orders_placed_at │ SQL  │                     │              │ Pending │           │   Yes    │
╰────────────┴─────────┴────────────────────────┴──────┴─────────────────────┴──────────────┴─────────┴───────────┴──────────╯

The original 1.1.0 and 1.2.0 rows are still there, now marked Undone. The files are still on disk, so the same versions show up again as Pending. Anyone looking at this database later can see what was applied, what was reversed, and what would run next. The schema matches:

 Schema |         Name          | Type  | Owner  
--------+-----------------------+-------+--------
 public | customers             | table | dblift
 public | dblift_migration_lock | table | dblift
 public | dblift_schema_history | table | dblift
(3 rows)

orders is gone; customers and DBLift's own two tables remain. When the fixed version is ready, or if you rolled back only to test the undo files, dblift migrate applies the pending versions again:

Found 2 pending migration(s)
Migration lock acquired successfully
Statement executed successfully 
Migration V1_1_0__create_orders_table.sql executed successfully in 11ms
Successfully applied migration V1_1_0__create_orders_table.sql
Migration V1_2_0__index_orders_placed_at.sql executed successfully in 3ms
Successfully applied migration V1_2_0__index_orders_placed_at.sql

Step 6: When there is no undo file

Before that re-apply, still at 1.0.0, we asked for everything. --target-version=0 means "roll back every migration":

dblift undo --target-version=0
Found 1 migration(s) to undo
ERROR: No undo script found for V1_0_0__create_customers_table.sql
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ FAILED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Command UNDO failed (Execution time: 14 ms)                                  ┃
┃ Error: No undo script found for V1_0_0__create_customers_table.sql           ┃
┃ Schema Version: 1.0.0                                                        ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

DBLift names the migration it can't reverse, leaves customers in place, and exits with code 1, so a CI job or deploy script stops. A migration with no paired undo script can't be reversed, and DBLift won't try to work out the reversal for you.

What undo can't do

Undo restores structure, not data. DROP TABLE orders reverses CREATE TABLE orders, but any rows that table held are gone. The same goes for dropping a column. An undo file is a way back to the previous schema. It doesn't replace a backup and can't recover rows. Take backups before destructive changes, and test your undo files on a copy before you rely on them in production.

Undo is also the wrong tool when a migration failed. On PostgreSQL the failed statements roll back. You fix the script, repair the failed history row if it is stale, then migrate again. Engines without transactional DDL need the schema reconciled by hand first. When a migration fails halfway covers each case, including the one where only a backup will do.

Rollback in the free tier

DBLift ships rollback in the free OSS tier. Flyway requires a Teams plan for the equivalent undo command (Flyway undo documentation). If you're comparing the two as a Flyway alternative, Move from Flyway shows how to carry your existing Flyway history across.

FAQ

Does the undo file need the same description as the migration?

No. Pairing is by version: U1_2_0__drop_orders_placed_at_index.sql reverses V1_2_0__index_orders_placed_at.sql. Use the description to say what the undo does.

Does dblift undo restore deleted data?

No. It runs the SQL in your undo file, which restores structure. Data removed by a drop comes back only from a backup.

How do I know a rollback will work before I need it?

Check two things ahead of time. dblift info should show Undoable: Yes for every version you might need to reverse. dblift undo --dry-run --show-sql --target-version=<version> shows the exact statements without running them. Then run the real undo once on a development copy, as in this walkthrough, so you're not running it for the first time during an incident.

Read next

DBLift is information technology / developer tools software. Contact: contact@dblift.com.