Flyway Community edition gets a rollback trick via version numbers

A blog post at aconcan.io describes a way to get rollback behaviour out of Flyway Community edition, which normally reserves managed rollback for the paid Flyway Teams tier. Flyway enforces two rules: migration version numbers must always increase, and the flyway_schema_history table is treated as the source of truth for what has run. The technique exploits both. Instead of asking Flyway to reverse a migration, the tool writes a new migration with a higher version number whose SQL body is the reverse of the original, then runs it as an ordinary forward migrate. To track what a rollback should actually undo, the tool records the diff of applied versions before and after every migrate call to a JSON state file, calling that diff "the batch": if a migrate run applies nothing new, the batch stays empty and an existing recorded batch is not overwritten. On rollback, the tool reverses the order of the batch (the last migration applied is undone first) and stages the corresponding hand-written "down" SQL files into a temporary directory with new, higher version numbers than any currently applied. Alongside those staged files it writes an afterMigrate.sql callback that deletes the round-trip rows, both the original migration versions and their synthetic "undo" versions, from flyway_schema_history once the migration succeeds, so the history table ends up with no record that any of it happened and the original migrations show up as pending again. The whole operation runs through Flyway's -group=true flag, which wraps every pending migration in a single database transaction, so either the full reversal and the history cleanup commit together or Postgres rolls everything back and the database is left untouched, provided the underlying DDL is itself transactional. The rollback function refuses to run if no batch was recorded, and it checks that a matching "down" script exists for every version in the batch before touching the database at all. The author lists explicit limitations: the technique does nothing to recover data lost to an irreversible "up" migration, such as a dropped column; "down" scripts have to be written by hand, which is also true on the paid tier; the recorded batch lives on the container filesystem, so destroying the container between a migrate and a rollback loses the state needed to undo it; and the approach deletes rows directly from Flyway's own bookkeeping table, which the author flags as something to be deliberate about even though the deletion is scoped only to the round-trip versions and runs inside the same transaction as the reversal.

Key facts

  • Flyway Community edition ships migrate, info, validate and repair but has no rollback command; managed rollback is reserved for the paid Flyway Teams tier.
  • The workaround disguises an undo as a new, higher-versioned forward migration whose SQL body reverses the original, since Flyway only requires that version numbers keep increasing.
  • A JSON state file records the diff of applied versions before and after each migrate call so the tool knows exactly which "batch" a later rollback should undo.
  • An afterMigrate.sql callback deletes both the original and the synthetic "undo" rows from the flyway_schema_history table, so the rolled-back migrations reappear as pending.
  • The whole rollback runs inside a single transaction via Flyway's -group=true flag, so it either fully applies or fully reverts, provided the DDL itself is transactional.

Why it matters

Flyway Community edition deliberately withholds managed rollback as a paid feature, and this post shows the gap can be closed without buying a licence or running raw SQL outside Flyway. The trick works entirely within Flyway's own rules: it never fights the constraint that version numbers only go up, it uses that constraint to smuggle a reversal through as an ordinary migration.

Who it affects

Anyone running Flyway Community edition for schema migrations, most directly teams that priced Flyway Teams specifically for the undo command and decided against paying for it. It also matters to anyone maintaining hand-written "down" scripts alongside their migrations, since the technique assumes those already exist.

How to use it

The setup needs two parallel directories per migration scope: the normal versioned migrations and a matching set of "down" scripts named by the same V prefix. A migrate wrapper diffs applied versions before and after each run and persists the new batch to a state file. A rollback function reads that batch, verifies a down script exists for every version in it, stages the down scripts under new incremented version numbers plus a cleanup afterMigrate.sql into a temporary directory, and calls Flyway's migrate against both the real migrations directory and the temp directory with -group=true so the whole operation commits or rolls back as one transaction. The temporary directory and its synthetic files are deleted automatically once the migrate call finishes.

How solid is it

The author backs each mechanical claim with the underlying Flyway behaviour: version numbers must only increase and the schema history table is authoritative, -group=true wraps all pending migrations in one transaction, and the afterMigrate callback runs inside Flyway's own execution and only fires after the migration succeeds. The design also guards against accidental double-runs, since an empty diff after a repeated migrate call does not overwrite a previously recorded batch, and against partial cleanup, since the deletion of round-trip rows happens in the same transaction as the reversal itself.

Risks and caveats

The technique does not protect against data loss from an irreversible forward migration, such as dropping a column that holds data. Down scripts still have to be written by hand, exactly as on the paid tier. The recorded batch that a rollback needs lives on the container filesystem, so destroying the container between a migrate and a rollback call loses the ability to undo it. And the approach deletes rows directly out of Flyway's own flyway_schema_history bookkeeping table, which the author flags as something to be deliberate about even though the deletion is scoped only to the exact round-trip versions and runs inside the same transaction as the reversal.

“version numbers must always go up and the schema history table is bible”

— the author