Rollback Strategies: Automatic, Custom, Tag-Based, and Count-Based
Rollback is the thing you build at 2am when the deployment broke production. The problem is that 2am is exactly the wrong time to discover your rollback blocks are missing, incomplete, or untested.
This article covers rollback from a strategy perspective: how Liquibase generates rollback, when you must write it yourself, how to handle change types that cannot be rolled back at all, and how to validate your rollback strategy in CI so it works when you need it.
Two Categories of Rollback
Liquibase rollback falls into two categories that determine how much work you need to do.
Automatic Rollback
For change types where the inverse operation is unambiguous, Liquibase generates rollback SQL automatically:
createTable→DROP TABLEaddColumn→DROP COLUMNrenameColumn→ rename backrenameTable→ rename backcreateIndex→DROP INDEXaddForeignKeyConstraint→DROP FOREIGN KEYaddUniqueConstraint→ drop constraintcreateSequence→DROP SEQUENCE
You can omit the rollback block for these and liquibase rollback will generate the correct SQL. However — and this is important — writing explicit rollback even for auto-supported types is still good practice because it makes the intent visible in code review and removes any ambiguity about what will happen.
Custom Rollback
For change types where the inverse cannot be determined without additional information, you must write the rollback block yourself:
dropTable— Liquibase doesn’t know the original schemadropColumn— Liquibase doesn’t know the original column definitionmodifyDataType— Liquibase doesn’t know the original typeinsert— Liquibase doesn’t know which rows were insertedupdate— Liquibase doesn’t know the original valuesdelete— Liquibase doesn’t know the deleted rowssql— Liquibase cannot invert arbitrary SQLloadData— Liquibase doesn’t know which rows came from which file
For these, if you omit rollback, the command fails with:
ERROR: No inverse to liquibase.change.core.DropTableChange created.
Cannot automatically generate rollback for this change.
Writing Custom Rollback
rollback with a change type
The most common form — the rollback block uses the same YAML change type syntax:
- changeSet:
id: "20260520-001"
author: abhay
comment: Drop the legacy audit_events table
changes:
- dropTable:
tableName: audit_events
rollback:
- createTable:
tableName: audit_events
columns:
- column:
name: id
type: BIGINT UNSIGNED
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: event_type
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: created_at
type: DATETIME
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
The rollback block accepts the same change types as changes. Multiple rollback steps are supported:
rollback:
- createTable:
tableName: audit_events
columns: [...]
- createIndex:
indexName: idx_audit_events_type
tableName: audit_events
columns:
- column: {name: event_type}
rollback with raw SQL
When the inverse operation is easier to express in SQL than in change type syntax:
- changeSet:
id: "20260520-002"
author: abhay
comment: Add generated column for full_name_search
changes:
- sql:
sql: >
ALTER TABLE users
ADD COLUMN full_name_search VARCHAR(512)
GENERATED ALWAYS AS (LOWER(CONCAT(full_name, ' ', email))) STORED;
rollback:
- sql:
sql: >
ALTER TABLE users DROP COLUMN full_name_search;
Multiple rollback steps
Rollback runs in the order listed — ensure operations are ordered correctly:
rollback:
- dropForeignKeyConstraint:
baseTableName: orders
constraintName: fk_orders_user_id
- dropTable:
tableName: orders
The foreign key must be dropped before the table, or MySQL throws a constraint violation. Rollback order is explicit — Liquibase does not reverse the change order automatically for rollback blocks.
Marking a Changeset as Non-Rollbackable
Some changes genuinely cannot be rolled back — data was migrated and the source is gone, a column was dropped and the data is unrecoverable without a backup. For these, tell Liquibase explicitly that rollback is not required:
- changeSet:
id: "20260521-001"
author: abhay
comment: Irreversible data cleanup — orphaned sessions deleted
changes:
- delete:
tableName: user_sessions
where: expires_at < '2026-01-01 00:00:00'
rollback:
- empty # or: not required
Using empty (or not required) tells Liquibase to skip this changeset during rollback rather than erroring. Without it, liquibase rollback will fail on this changeset because it cannot auto-generate rollback for delete.
Never use empty as a shortcut to avoid writing rollback. Use it only when rollback is genuinely impossible or when the changeset’s effect is safe to leave in place (e.g., adding an index — dropping it is the rollback, but leaving it is harmless).
The Four Rollback Commands
rollback –tag (use this in production)
Rolls back all changesets applied after a given tag. This is the command to use in production because it is explicit — you specify exactly which state to return to.
# Preview
liquibase rollbackSQL --tag=v1.2.0
# Apply
liquibase rollback --tag=v1.2.0
The tag marks a point in DATABASECHANGELOG. All changesets with ORDEREXECUTED greater than the tagged row are rolled back, in reverse order.
Prerequisite: The tag must have been applied before update ran. This is why liquibase tag before liquibase update is a non-negotiable habit (Article 5). Without a tag, you cannot use this command.
rollbackCount N
Rolls back exactly N of the most recently applied changesets.
liquibase rollbackCountSQL 3
liquibase rollbackCount 3
Use when: You did not set a tag and need to undo your last few changesets during development. Fragile in production — you must know exactly how many changesets ran in the last deployment, which is not always clear if multiple CI runs happened close together.
rollbackToDate
Rolls back all changesets applied after a specific date and time.
liquibase rollbackToDateSQL "2026-05-20 14:30:00"
liquibase rollbackToDate "2026-05-20 14:30:00"
Use when: You know the time of the bad deployment but didn’t set a tag. This is the last-resort escape hatch. The date matches against DATEEXECUTED in DATABASECHANGELOG.
updateTestingRollback
Not strictly a rollback command — it’s a CI validation tool. It applies all pending changesets, rolls them all back, then re-applies them. If any rollback block fails, the command errors.
liquibase updateTestingRollback
Run this in your CI pipeline on every PR that adds new changesets. It proves rollback works in a safe environment.
CI Validation: futureRollbackSQL
futureRollbackSQL generates the rollback SQL for all changesets that are pending (not yet applied). It does not modify the database.
liquibase futureRollbackSQL
This is the command to run in CI as a pre-deployment gate:
# GitHub Actions example
- name: Validate rollback is possible
run: |
liquibase futureRollbackSQL \
--output-file=rollback-preview.sql
# If any changeset has no rollback, this command exits non-zero
If any pending changeset has no rollback and does not have empty/not required, futureRollbackSQL errors with a clear message identifying which changeset is missing rollback. This fails the pipeline before the problematic changeset ever reaches production.
Also use futureRollbackCountSQL and futureRollbackFromTagSQL for more targeted validation:
# Generate rollback SQL for the next 5 changesets only
liquibase futureRollbackCountSQL 5
# Generate rollback SQL from a specific tag forward
liquibase futureRollbackFromTagSQL --tag=v1.2.0
Complete CI Pipeline Pattern
Here is a complete CI pipeline stage that validates both forward and rollback:
# .github/workflows/db-validate.yml
name: Database Migration Validation
on: [pull_request]
jobs:
validate-migrations:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ecommerce_test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- name: Validate changelog syntax
run: liquibase validate
- name: Check pending changesets
run: liquibase status
- name: Preview forward SQL
run: liquibase updateSQL
- name: Validate rollback is possible for all pending changesets
run: liquibase futureRollbackSQL
- name: Apply and test rollback round-trip
run: liquibase updateTestingRollback
This catches four categories of problems before merge:
validate— syntax errors, checksum mismatchesstatus— unexpected pending changesetsupdateSQL— SQL that would fail on execution (type errors, missing references)futureRollbackSQL— missing rollback blocksupdateTestingRollback— rollback SQL that fails when actually executed
Rollback Planning by Change Category
Different types of changes need different rollback strategies. Here is a decision guide.
DDL: Table and column changes
| Change | Rollback Strategy |
|---|---|
createTable | Auto (DROP TABLE) |
dropTable | Explicit: recreate full table definition |
addColumn | Auto (DROP COLUMN) |
dropColumn | Explicit: recreate column with original type and constraints |
renameColumn | Auto (rename back) |
modifyDataType | Explicit: revert to original type — test for data truncation |
addForeignKeyConstraint | Auto (drop constraint) |
dropForeignKeyConstraint | Explicit: recreate constraint |
createIndex | Auto (DROP INDEX) |
DML: Data changes
| Change | Rollback Strategy |
|---|---|
insert (small, known rows) | Explicit: DELETE with deterministic WHERE |
insert (large, unknown) | Mark as empty — restore from backup |
update (all rows) | Explicit: revert value — may be lossy, track changed rows |
update (conditional) | Explicit: reverse condition — consider tracking rows |
delete | Mark as empty — data is gone without backup |
loadData | Explicit: DELETE all rows — truncate or filtered delete |
Stored objects
| Change | Rollback Strategy |
|---|---|
createView | Auto (DROP VIEW) |
dropView | Explicit: recreate view SQL |
createProcedure | Explicit: DROP PROCEDURE in rollback |
sql (arbitrary) | Explicit: hand-write the inverse |
Rollback That Can’t Undo Data Loss
Some rollbacks are structural but not data-preserving. Know the difference:
Safe rollback: Dropping an index can be rolled back by recreating it — no data is lost.
Structural-only rollback: Dropping a column can be rolled back by adding the column back — but the data that was in it is gone. The rollback restores structure, not data.
No rollback: Deleting rows, dropping tables that weren’t empty, truncating. The only real rollback is a database restore from backup.
For the second and third categories, the right answer in production is often not to use Liquibase rollback at all — it is to restore from a pre-deployment snapshot and re-deploy the corrected changesets. Liquibase rollback is most reliable for DDL changes. For data changes, database backups are the safety net.
Common Mistakes
Not setting a tag before deployment: Without a tag, rollback --tag is not available. Your only options are rollbackCount (requires knowing exactly how many changesets ran) or rollbackToDate (requires the exact timestamp). Tag before every deployment — no exceptions.
Using empty rollback to avoid writing it: If a changeset is rolled back and its rollback is empty, the database is left with the change applied. In a multi-changeset rollback, this can leave the schema in an inconsistent state where later changesets were undone but an earlier one was not. Use empty only when the change is genuinely safe to leave in place.
Rollback order in multi-step changesets: Rollback steps in a rollback block run in the order they are listed, not in reverse. If you drop a foreign key in changes and then drop the table, your rollback block must recreate the table first, then add the foreign key — not the reverse. Write rollback order explicitly and carefully.
Best Practices
- Write rollback at changeset creation time — when you write the change, the inverse is clear; six months later it requires archaeology
futureRollbackSQLin every CI pipeline — gates the PR on rollback being possible, not just forward migration workingupdateTestingRollbackon every PR with new changesets — proves rollback executes successfully, not just that SQL was generated- Tag before every non-dev deployment —
liquibase tag v1.x.yis the first step in every deployment runbook - For data deletions and large data migrations, mark
emptyand rely on DB backups — Liquibase rollback is for schema changes; data recovery needs restore - Track changed rows in update migrations — a
migrated_from_Xflag column lets rollback be exact rather than lossy
What You’ve Learned
- Automatic rollback covers structural creation changes; custom rollback is required for drops, type changes, and all DML
rollback --tagis the only production-safe rollback command — it requires a tag set before deploymentrollbackCountandrollbackToDateare escape hatches for when you forgot to tagfutureRollbackSQLvalidates that rollback is possible for pending changesets — run it in CI before mergeupdateTestingRollbackproves rollback actually executes — run it in CI too- Rollback restores structure, not data — for data loss, database backups are the real safety net
emptyrollback is for genuinely irreversible changes — not a shortcut to skip writing rollback
Next: Article 10 — Contexts and Labels: Multi-Environment Filtering — using contexts and labels to run different changesets in dev, staging, and production without maintaining separate changelogs.