Part 9 of 18

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:

  • createTableDROP TABLE
  • addColumnDROP COLUMN
  • renameColumn → rename back
  • renameTable → rename back
  • createIndexDROP INDEX
  • addForeignKeyConstraintDROP FOREIGN KEY
  • addUniqueConstraint → drop constraint
  • createSequenceDROP 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 schema
  • dropColumn — Liquibase doesn’t know the original column definition
  • modifyDataType — Liquibase doesn’t know the original type
  • insert — Liquibase doesn’t know which rows were inserted
  • update — Liquibase doesn’t know the original values
  • delete — Liquibase doesn’t know the deleted rows
  • sql — Liquibase cannot invert arbitrary SQL
  • loadData — 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:

  1. validate — syntax errors, checksum mismatches
  2. status — unexpected pending changesets
  3. updateSQL — SQL that would fail on execution (type errors, missing references)
  4. futureRollbackSQL — missing rollback blocks
  5. updateTestingRollback — 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

ChangeRollback Strategy
createTableAuto (DROP TABLE)
dropTableExplicit: recreate full table definition
addColumnAuto (DROP COLUMN)
dropColumnExplicit: recreate column with original type and constraints
renameColumnAuto (rename back)
modifyDataTypeExplicit: revert to original type — test for data truncation
addForeignKeyConstraintAuto (drop constraint)
dropForeignKeyConstraintExplicit: recreate constraint
createIndexAuto (DROP INDEX)

DML: Data changes

ChangeRollback 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
deleteMark as empty — data is gone without backup
loadDataExplicit: DELETE all rows — truncate or filtered delete

Stored objects

ChangeRollback Strategy
createViewAuto (DROP VIEW)
dropViewExplicit: recreate view SQL
createProcedureExplicit: 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
  • futureRollbackSQL in every CI pipeline — gates the PR on rollback being possible, not just forward migration working
  • updateTestingRollback on every PR with new changesets — proves rollback executes successfully, not just that SQL was generated
  • Tag before every non-dev deploymentliquibase tag v1.x.y is the first step in every deployment runbook
  • For data deletions and large data migrations, mark empty and rely on DB backups — Liquibase rollback is for schema changes; data recovery needs restore
  • Track changed rows in update migrations — a migrated_from_X flag 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 --tag is the only production-safe rollback command — it requires a tag set before deployment
  • rollbackCount and rollbackToDate are escape hatches for when you forgot to tag
  • futureRollbackSQL validates that rollback is possible for pending changesets — run it in CI before merge
  • updateTestingRollback proves rollback actually executes — run it in CI too
  • Rollback restores structure, not data — for data loss, database backups are the real safety net
  • empty rollback 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.