Skip to main content

Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and Baselines

A from-source comparison of Flyway 12.4.0 and Liquibase 5.0.3 on Spring Boot 4.1.1: checksum validation, out-of-order migrations, baselining an existing database, why Flyway Community has no working rollback, Liquibase’s very different rollback and locking model, a real Liquibase 5.0.3 defect found and verified during testing, the FSL license change and its fallout at the ASF and Keycloak, and what happens when you run both tools against one database. Every claim traces to a companion project you can clone and run yourself.

If you’ve added a database to a Spring Boot project, you’ve probably typed spring.jpa.hibernate.ddl-auto=update at least once, watched it work on your laptop, and felt a small chill about what it would do to a production table with real customer rows in it. That chill is correct. Letting Hibernate infer your schema from your entities is fine for a weekend project and genuinely dangerous for anything you plan to keep running — it can silently drop a column it decided you no longer need.

Flyway and Liquibase exist to replace that guesswork with something you can read, review in a pull request, and run the same way on every environment: a versioned, tracked, incremental history of every change your schema has ever gone through. Spring Boot 4.1 ships first-class support for both, and this article is a from-source, actually-run comparison of what each one does well, where each one bites you, and what changed recently enough that older blog posts about them are now wrong in specific, checkable ways.

Everything below was built and run against Spring Boot 4.1.1, not described from memory or from either tool’s marketing page. Every code block links to the file it came from and every output block is quoted verbatim from a committed transcript, both in a small companion project you can clone and run yourself: db-migrations-flyway-liquibase.

Versions used throughout this article, verified against the actual resolved dependency tree, not assumed from the BOM’s headline number.

Spring Boot 4.1.1 (released August 20, 2026) manages Flyway and Liquibase versions via spring-boot-starter-flyway and spring-boot-starter-liquibase. Running mvn dependency:tree against that BOM resolves to Flyway 12.4.0 (April 2026) and Liquibase 5.0.3 (May 15, 2026) — not necessarily either project’s newest release at the time you read this, since Flyway had already reached 13.6.0 and Liquibase 5.0.4 independently by the time this was written. Database: H2 2.4.240. JDK: 25.

The problem: everyone’s database needs to agree with everyone’s code

Here’s the situation both tools solve, stated the way you’d actually hit it. You add a status column to your orders table on your laptop. Your code now reads and writes that column. You commit your code. A teammate pulls it, and their database doesn’t have the column — their app breaks on the first query. Your staging environment doesn’t have it either. Neither does production, and unlike your teammate’s laptop, you can’t just drop the database and recreate it there.

What you actually need is a record: an ordered list of every schema change your project has ever made, that any database — your laptop, a teammate’s laptop, staging, production — can compare itself against and catch up from wherever it currently is. Flyway and Liquibase are both, at their core, exactly that: a tool that keeps a table inside your own database listing which changes have already run, so it can figure out which ones haven’t and apply only those, in order, every time your application starts.

One migration set, three databases, three starting points Your laptop V1 create_orders V2 add_status history table says: V1, V2 nothing left to run Teammate’s laptop V1 create_orders V2 add_status ← pending history table says: V1 runs V2 on next startup Production V1 ← pending V2 ← pending history table: empty runs V1 then V2, in order Same two migration files everywhere. Each database’s own tracking table is what tells the tool which ones it’s already seen — nobody has to remember, and nobody has to run anything by hand.

That tracking table is the one idea underneath both tools, and it’s worth holding onto before anything else, because almost every surprising thing in this article traces back to what that table does or doesn’t know. Flyway calls it flyway_schema_history; Liquibase calls it DATABASECHANGELOG. Everything from here is really about two different opinions on how to identify a change, what to do when the table and the real schema disagree, and what happens when you need to go backwards.

We’ll build up from the smallest thing that works, through the failure modes that actually cost people time — a checksum mismatch, an out-of-order migration, adopting an existing database, running two instances at once — into Liquibase’s different model for the same problem, its very different rollback story, a real defect this module’s own tests tripped over, a 2025 license change with live compliance consequences at two real open-source foundations, and what happens if you try to run both tools against one database at once. Full source for every scenario is in db-migrations-flyway-liquibase; its README indexes all fifteen documentation chapters this article draws on.

The smallest Flyway setup that works

Flyway’s version of “the smallest thing that works” is two SQL files and no other configuration. Add spring-boot-starter-flyway to your dependencies, drop files under src/main/resources/db/migration, and Flyway runs them on startup, in order, before your application context finishes wiring up.

The filename is the metadata. V1__create_customer.sql means “versioned migration, version 1, description create_customer” — a capital V, the version number, two underscores, then a description Flyway derives from whatever’s left of the filename:

create table customer (
    id bigint generated by default as identity primary key,
    name varchar(120) not null,
    email varchar(200) not null unique
);

That’s the entirety of V1__create_customer.sql. A second file, V2__seed_customer.sql, inserts two rows. FlywayHappyPathTest runs both and reads back Flyway’s own bookkeeping table, flyway_schema_history:

installed_rank | version | description                               | type  | checksum    | success
---------------+---------+-------------------------------------------+-------+-------------+--------
-1             | NULL    | << Flyway Schema History table created >> | TABLE | NULL        | true
1              | 1       | create customer                           | SQL   | 1461549807  | true
2              | 2       | seed customer                             | SQL   | -1214875726 | true

(from docs/output/01-flyway-happy-path.txt)

Two things in that table are easy to get wrong from reading the docs alone, and both were caught here by an assertion that failed against real output rather than by reading about them. First, the description column drops underscores for spaces — the file says create_customer, the row says create customer. Second, checksum is a signed 32-bit CRC of the whole file’s content, not of the SQL Flyway actually ran — which matters a lot in the next section.

A trap that costs about twenty minutes the first time you hit it. Flyway creates and queries its own tracking table using quoted, lowercase identifiers — "flyway_schema_history". H2 folds unquoted identifiers to uppercase by default, so select * from flyway_schema_history run from your own code looks for FLYWAY_SCHEMA_HISTORY and quietly finds nothing. This module’s own diagnostics controller hit exactly this while it was being written. The fix is to quote it the way Flyway does: select * from "flyway_schema_history". Liquibase’s own tables don’t need this — its unquoted DDL for DATABASECHANGELOG folds consistently, so a plain unquoted query against it works fine, which is what makes this specifically a Flyway-on-H2 asymmetry rather than a general “always quote your identifiers” rule.

The mechanism behind this — what Flyway actually does on every startup, one migration at a time — and the case-folding trap above are both covered at more length in chapter 2, Anatomy of a Flyway migration run, including the exact H2 grammar page describing the case-folding default.

What happens when you edit a migration that already ran

Here’s a failure mode that costs teams real time the first time they hit it: someone “just tweaks” a migration file that has already applied somewhere — widening a column, fixing a typo in a comment — instead of writing a new migration. FlywayChecksumMismatchTest reproduces exactly that: start a context against V1__init.sql, let it apply cleanly, edit that same file on disk afterward, then start a second context against the same database.

Flyway’s default behavior (spring.flyway.validate-on-migrate=true) compares every already-applied migration’s recorded checksum against the file’s current checksum before attempting to run anything new, and the second startup fails outright, before touching the schema at all:

FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : -712784830
-> Resolved locally    : 756220147
Either revert the changes to the migration, or run repair to update the schema history.

(from docs/output/02-flyway-checksum-mismatch.txt)

That message is doing real work. Nothing about the schema itself is inconsistent at this point — the failure happens before any SQL runs, which is the entire point of checking first. A team that reaches for flyway repair out of habit whenever this appears is telling Flyway “the file changed on purpose, update your record” — correct for a comment or formatting fix, and very wrong for “I actually need this table to have the new behavior now,” which needs a new V3__... file, not a rewrite of a migration that already ran somewhere else.

Checksum validation is not a schema-drift detector. It checks the migration file against what Flyway already recorded, not the file against the live schema. If someone hand-edits a table directly after the migration ran, this check notices nothing at all — it’s a version-control safety net for your migration files, not a guarantee that the database still looks like they say it should.

Full mechanism, plus what repair actually rewrites in flyway_schema_history when you do mean to use it, is in chapter 3, Checksum validation.

What happens when a migration lands out of order

Picture two branches, both adding “the next migration.” One ships V3 and merges quickly. The other’s V2 sits in a slow-to-review pull request and lands afterward. By the time V2 finally merges, every environment that deployed the first branch already has V3 applied. What happens when V2 shows up late?

The common assumption is that Flyway quietly skips the late-arriving lower version and carries on. FlywayOutOfOrderTest was originally written expecting exactly that — and the real run corrected it. V1 and V3 apply on a first startup; V2 is dropped in afterward; a second startup against the same database fails completely:

context failed to start: FlywayValidateException: Validate failed: Migrations have failed validation
Detected resolved migration not applied to database: 2.
To ignore this migration, set -ignoreMigrationPatterns='*:ignored'. To allow executing this migration, set -outOfOrder=true.

(from docs/output/03-flyway-out-of-order.txt)

history so far: V1 √, V3 √ — then V2 arrives V1 V3 V2? out-of-order=false (default) whole startup fails same arrival, out-of-order=true V2 runs, slotted in after V3 flyway_schema_history keeps the real apply order: rank1=V1, rank2=V3, rank3=V2 Flyway does not renumber or reorder anything to make it look sequential again.

Validation runs before migration, and by default (spring.flyway.out-of-order=false) a resolved migration versioned lower than the highest one already applied is a validation failure, not a no-op — nothing runs and the context never comes up. Setting spring.flyway.out-of-order=true, the fix the error message names directly, changes that: a third startup in the same test confirms V2 now runs and lands at installed_rank 3, sitting after rank 2’s V3 — Flyway records the real order it actually applied things in, it doesn’t retroactively renumber anything to look sequential again.

More on why this defaults to failing loudly, plus what Flyway’s own startup log says about reproducibility once you flip the switch, is in chapter 4, Out-of-order migrations.

  • Going deeper: outOfOrder is a blunt, global switch — turning it on doesn’t allow just the one late migration you expected, it permits any lower-numbered migration to slot in from then on, and Flyway’s own log says so verbatim: outOfOrder mode is active. Migration of schema may not be reproducible.
  • ignoreMigrationPatterns — the error’s other suggested fix, for permanently ignoring one specific migration instead of relaxing ordering globally.
  • A team that hits this regularly is usually missing a CI check that fails a PR whose migration version number is lower than what’s already merged to main — cheaper than leaning on outOfOrder at all.

Migrations that are meant to change: repeatable migrations

Versioned migrations are one-shot by design — apply once, never again, on this database, ever. Views, stored procedures and seed-reference-data scripts don’t fit that model at all; you actually want them to re-apply whenever their content changes, regardless of what version number anything else is at. That’s what a repeatable migration, prefixed R__ instead of V<n>__, is for.

FlywayRepeatableTest ships one versioned migration alongside R__invoice_summary_view.sql, a view definition. It runs once on the first startup, like anything else, landing with version recorded as NULL — that’s how the history table tells a repeatable migration apart from a versioned one; there’s no version number to have:

installed_rank | version | description                               | type  | checksum
---------------+---------+-------------------------------------------+-------+------------
-1             | NULL    | << Flyway Schema History table created >> | TABLE | NULL
1              | 1       | init                                      | SQL   | -192856793
2              | NULL    | invoice summary view                      | SQL   | -2025099931

The test then widens the view to also sum amount_cents, without touching the filename or any version number, and starts a second context against the same database:

installed_rank | version | description                               | type  | checksum
---------------+---------+-------------------------------------------+-------+------------
-1             | NULL    | << Flyway Schema History table created >> | TABLE | NULL
1              | 1       | init                                      | SQL   | -192856793
2              | NULL    | invoice summary view                      | SQL   | -2025099931
3              | NULL    | invoice summary view                      | SQL   | 750256380

(both tables from docs/output/04-flyway-repeatable.txt)

A brand new row appears — same description, new checksum — and the view really was redefined: querying it afterward returns the new total_cents column. Set this next to chapter 3: for a versioned migration, a changed checksum is a hard failure. For a repeatable one, it’s the trigger to rerun. It’s the exact same mechanism — comparing a checksum against what flyway_schema_history already recorded — with the opposite consequence, and the only thing deciding which applies is a single filename prefix.

Chapter 5, Repeatable migrations covers ordering — repeatable migrations always run last, after every pending versioned one — in more depth.

  • Going deeper: repeatable migrations run in classpath order (alphabetically, by default), not interleaved by when each was last changed — a detail that matters once you have more than one.
  • Flyway’s repeatable migration docs cover the full ordering and checksum-comparison rules.
  • A view is the textbook use case, but the same mechanism works for anything idempotent — a stored procedure body, or a MERGE/upsert of reference data that should always reflect what the file currently says.

Adopting Flyway on a database it didn’t build

Every migration tool eventually meets a database it didn’t create. Someone hand-ran DDL for years, and now the team wants Flyway to take over from here — without dropping the schema and replaying history that never actually happened through Flyway. FlywayBaselineTest sets this up literally: a legacy_account table is created with plain JDBC, with one row already in it, before Flyway is ever involved. Pointing an unconfigured Flyway at that database refuses outright, using the exact same safety check the mental-model section relied on:

FlywayException: Found non-empty schema(s) "PUBLIC" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.

Turning on spring.flyway.baseline-on-migrate=true (with baseline-version=1 and a baseline-description) fixes it. The migration written to describe the schema that already exists, V1__init.sql, never actually runs; Flyway instead inserts a row of type BASELINE claiming version 1 is already accounted for, and only the next migration executes for real:

installed_rank | version | description                               | type     | success
---------------+---------+-------------------------------------------+----------+--------
-1             | NULL    | << Flyway Schema History table created >> | TABLE    | true
1              | 1       | pre-flyway schema                         | BASELINE | true
2              | 2       | add status column                         | SQL      | true

(from docs/output/05-flyway-baseline.txt)

The pre-existing row survives untouched and gains the new column, exactly as you’d want:

ID | OWNER           | STATUS
---+-----------------+-------
1  | pre-flyway-data | ACTIVE
The baseline row’s description is not a fixed string. It’s exactly whatever spring.flyway.baseline-description was set to — in this test, literally "pre-flyway schema". A different, fixed marker — << Flyway Schema History table created >> — belongs to a completely different row (rank -1, type TABLE), created the instant the history table itself is created, whether or not baselining ever happens. Confusing the two is an easy way to write a broken assertion or a broken monitoring query — this module’s own first draft did exactly that.

baselineVersion decides what “already accounted for” actually means, and later in this article we’ll see the same mechanism used with baseline-version=0 instead of 1, for a database whose existing schema doesn’t match any of your migration files at all — see chapter 6, Baselining an existing database for the full mechanism and a worked example of picking the wrong baseline version.

  • Going deeper: Flyway’s baseline command reference — the one-time call baselineOnMigrate=true triggers automatically on first startup.
  • Baselining is a one-way door in the sense that matters: once version 1 is marked baselined, Flyway never again checks whether the schema it describes actually matches that file’s content — its checksum is simply never looked at again for this database.

Why there is no undo in Flyway Community

The Flyway class in the Community, Apache-2.0-licensed flyway-core artifact this module depends on has a public method called undo(). It compiles. Its Javadoc reads like every other command. Nothing in its signature suggests it won’t work — and that’s exactly the trap. FlywayUndoTest migrates a real database, then calls flyway.undo() on it, and the call throws at runtime:

flyway.undo() threw: org.flywaydb.core.internal.license.FlywayRedgateEditionRequiredException
message: Flyway Redgate Edition Required: undo is not supported by OSS Edition
Download Redgate Edition for free: https://rd.gt/3GGIXhh

(from docs/output/06-flyway-undo-teams-required.txt)

undo isn’t a special case singled out here — it’s one of a whole family of commands that ship as compiled, callable, do-nothing stubs in the Community jar. Rather than take that on faith, FlywayCommunityCommandSurfaceTest opens the actual jar on the test classpath at runtime and lists every class under org.flywaydb.core.internal.proprietaryStubs — this list is read off the real artifact Maven Central serves, not copied from a marketing page:

- auth
- check
- deploy
- diff
- difftext
- generate
- licensingconfigurationextensionstub.class
- model
- offlinepermitconfigurationextensionstub.class
- pattokenconfigurationextensionstub.class
- prepare
- undo

(from docs/output/07-flyway-proprietary-stub-commands.txt)

flyway-core (Community, Apache-2.0) migrate() — real baseline() — real clean() — real undo() — compiles, throws at runtime FlywayRedgateEditionRequiredException only at the moment undo() is actually called

The practical consequence is worth sitting with: a compile-time check, a code review, or your IDE’s autocomplete cannot tell you that undo() needs a Redgate Teams or Enterprise license. Only calling it — or reading the bytecode the way this test does — does. For everyday rollback needs on the Community edition, the honest options are the ones Flyway’s own free command set actually supports: write and ship a new, forward-only migration that reverses the change, or restore from a backup taken before the migration ran. Both are less elegant than flyway undo, and both actually work on the free jar.

Chapter 7, Why there is no undo has the full stub list and a note on why this stub-and-throw pattern is arguably the more honest of the two ways to gate a feature — compare it later against how Liquibase gates its own commercial features.

  • Going deeper: Flyway’s own editions comparison — which commands need which tier, from the vendor.
  • This isn’t a licensing violation to work around: the stub classes exist so a Community user calling one of these APIs gets a clear, named exception instead of a NoSuchMethodError or a silent no-op.

Two instances starting at once: how Flyway’s lock actually works

A rolling deploy starts several instances of the same application at close to the same moment, all pointed at the same database, all carrying the same migrations. If two of them both decide “I need to run V1” at once, what stops the second from either duplicating work or corrupting the history table?

FlywayConcurrentMigrateTest answers this empirically instead of reading about it: it starts two real Flyway instances on two real threads, both pointed at the same H2 file (opened with AUTO_SERVER=TRUE so genuinely separate JDBC connections can share it), both migrating a deliberately slow, 800ms Java-based migration.

instance A migrate() took 932ms
instance B migrate() took 910ms
wall-clock time for both, run concurrently: 937ms

-- flyway_schema_history after both finished --
installed_rank | version | description                               | success
---------------+---------+-------------------------------------------+--------
-1             | NULL    | << Flyway Schema History table created >> | true
1              | 1       | SlowMigration                             | true

(from docs/output/08-flyway-concurrent-lock.txt)

The migration ran exactly once — the test asserts that directly by counting rows. What’s more interesting is the timing: both instances took almost the same, almost-full 800-900ms, and the wall clock for both together is close to that one duration, not their sum. That shape is the real proof of what’s happening underneath: Flyway takes a row-level lock on its own schema history table before checking what needs to run. The winner holds the lock for the whole migration; the loser blocks on that same lock for nearly the full window, then — once it finally acquires it — finds the migration already recorded and returns almost immediately. There’s no external coordinator and no separate lock table: the schema history table is the lock.

instance A holds the row lock — runs migration (≈800ms) instance B blocked, waiting for the same row lock lock acquired → nothing left to do → returns Both calls “took” roughly the same 900ish ms — instance B’s time is almost entirely the wait, not the (nonexistent, for it) work. Wall clock for both ≈ one migration’s length, not the sum.
A naive assertion here gets the shape of the proof backwards. Comparing wall-clock time against the sum of both individual durations expects serialization to look like “one after the other, end to end” — the wrong model for two threads submitted at the same instant, one blocking on the other’s lock. The right check is that both individual durations are long, proving the loser genuinely waited rather than racing ahead unlocked. This module’s own test was rewritten once to fix exactly that reasoning error.

Liquibase solves the identical problem with a completely different mechanism — a dedicated, separate lock table rather than a row lock on the history table itself — and it behaves very differently under contention, covered later in this article. Full detail on Flyway’s side is in chapter 8, Concurrent startup and locking.

  • Going deeper: Flyway’s migrations reference covers the schema history table this row lock lives on; Flyway’s locking behavior itself isn’t separately documented in detail, which is exactly why this section measured it directly instead of citing it.

Liquibase’s different unit of change: changesets and changelogs

Everything so far has been Flyway. Liquibase solves the same underlying problem — a tracking table, applied once, in order — but its unit of change is shaped completely differently, and that shape is worth understanding on its own before comparing rollback behavior, since it explains almost everything that follows.

Flyway’s unit is one file per migration, identified by a version number in the filename. Liquibase’s unit is a changeset inside a changelog: one YAML (or XML, JSON, or SQL) document listing every change in order, rather than one file per change. LiquibaseHappyPathTest uses a small inline changelog — a createTable changeset, then an insert changeset — run through the classic Liquibase facade directly, with no Spring context in the way, so the mechanics are visible on their own:

-- databasechangelog --
ID               | AUTHOR | FILENAME    | ORDEREXECUTED | EXECTYPE
-----------------+--------+-------------+---------------+---------
1-create-account | ankurm | master.yaml | 1             | EXECUTED
2-seed-account   | ankurm | master.yaml | 2             | EXECUTED

-- account table --
ID | OWNER
---+------------------
1  | Katherine Johnson

(from docs/output/09-liquibase-happy-path.txt)

DATABASECHANGELOG is Liquibase’s equivalent of flyway_schema_history, but a changeset’s identity is a different kind of thing entirely from Flyway’s version numbers: it’s the triple of id, author, and the changelog filename it was declared in. Two changesets with the same id in two different files are different changesets to Liquibase; the same id twice in the same file is a configuration error. There’s no numeric ordering at all — order comes purely from position in the changelog, top to bottom.

master.yaml changeSet id: 1-create-account changeSet id: 2-seed-account DATABASECHANGELOG id=1-create-account, author=ankurm, filename=master.yaml → EXECUTED id=2-seed-account, author=ankurm, filename=master.yaml → EXECUTED

Chapter 9, Liquibase: anatomy of an update covers the newer command-framework internals visible in Liquibase 5’s stack traces, plus preconditions and contexts/labels as the natural next things to read once changesets make sense.

  • Going deeper: Liquibase’s changelog structure documentation — the full list of supported formats and the changeset identity rules above.
  • Liquibase’s classic update() facade internally delegates to a CommandScope/UpdateCommandStep implementation rather than the older direct-execution path — visible in every stack trace this module produced. It doesn’t change behavior for ordinary changesets, but it’s worth knowing when a stack trace looks unfamiliar next to older Liquibase tutorials.

Rollback: when Liquibase can invert a change, and when it can’t

Chapter 7 showed Flyway Community has no working rollback at all. Liquibase’s answer is a real rollback() call — but whether it actually works depends entirely on which kind of change you’re rolling back, and that distinction is easy to miss until it fails on you in production.

createTable is one of the change types Liquibase knows how to invert on its own — it just runs DROP TABLE. LiquibaseRollbackAutoTest writes no rollback: block anywhere in its changelog, calls update(), confirms the table exists, then calls rollback(1, ...):

after update(): session table exists = true
after rollback(1): session table exists = false

(from docs/output/10-liquibase-rollback-auto.txt)

insert is not in that auto-invertible list. LiquibaseRollbackFailTest applies a createTable changeset followed by an insert changeset, then calls rollback(1, ...) — asking to roll back just the most recent changeset, the insert — and it fails for real:

after update(): both changesets applied
rollback(1) threw: liquibase.exception.CommandExecutionException
message: liquibase.exception.LiquibaseException: liquibase.exception.RollbackFailedException: liquibase.exception.RollbackImpossibleException: No inverse to liquibase.change.core.InsertDataChange created

(from docs/output/11-liquibase-rollback-no-inverse.txt)

The fix is to write the inverse yourself. LiquibaseRollbackExplicitTest is the same insert changeset, this time carrying its own rollback: block:

- insert:
    tableName: audit_log
    columns:
      - {column: {name: event, value: 'system-start'}}
  rollback:
    - delete:
        tableName: audit_log
        where: event='system-start'

rollback(1, ...) now succeeds — the table stays, the row is gone:

-- audit_log after rollback(1) — table still exists, the row is gone --
ID | EVENT
---+------
(0 rows)

(from docs/output/12-liquibase-rollback-explicit.txt)

createTable auto-invertible → DROP TABLE insert, no rollback: RollbackImpossibleException insert + rollback: works, runs the delete Whether rollback() works is a property of the change TYPE, plus whether you wrote a rollback: block — never a global Liquibase capability you can rely on by default.

A team relying on rollback in production is really relying on discipline: every changeset that touches data needs its rollback written and tested when the changeset itself is written, not discovered missing during an actual incident — rollback(1, ...) failing is the worst possible moment to learn insert has no inverse. Chapter 10, Rollback: auto-generated vs explicit has the full picture.

  • Going deeper: Which change types Liquibase can auto-generate a rollback forcreateTable, addColumn and a handful of other structural changes; almost anything involving data needs an explicit rollback: block.
  • Liquibase also supports rollbackCount, rollbackToDate and rolling back by tag — this module exercises only the single-changeset rollback(int, ...) overload.

Liquibase’s lock is slower to release, by design

Liquibase’s answer to the concurrent-startup question is a dedicated table, DATABASECHANGELOGLOCK, holding exactly one row that a real update() call has to acquire before touching anything else. LiquibaseConcurrentUpdateTest runs the same experiment as the Flyway version earlier: two instances, one deliberately slow (800ms), started on two real threads against the same database.

instance A update() took 874ms
instance B update() took 10097ms
wall-clock time for both, run concurrently: 10099ms

-- databasechangelog after both finished --
ID            | AUTHOR | EXECTYPE
--------------+--------+---------
1-slow-change | ankurm | EXECUTED

(from docs/output/13-liquibase-lock-contention.txt)

The changeset ran exactly once — the same guarantee as Flyway. The shape of the wait is completely different, though, and it’s a real, verified difference: instance B’s call took over ten seconds for 800ms of underlying work. That’s not noise. Liquibase’s LockService doesn’t retry immediately when a lock is held — it polls, and liquibase.changeLogLockPollRate‘s default, confirmed by decompiling GlobalConfiguration‘s own bytecode with javap, is 10 seconds:

liquibase.changeLogLockPollRate          -> default 10 (seconds between checks while the lock is held)
liquibase.changeLogLockWaitTimeInMinutes -> default 5 (minutes before giving up entirely)

(from docs/output/16-liquibase-lock-defaults-javap.txt)

instance A ≈800ms work instance B polling every ~10s until it happens to check right after A released the lock Flyway’s row lock (earlier in this article) releases the waiter the instant it’s free — total ≈900ms. Liquibase’s default poll rate means the same race can cost up to ~10s of pure waiting, for sub-second work.

A losing instance in a real rolling deploy can therefore sit doing nothing for up to ten seconds even though the work it’s waiting on took under a second — worth knowing before you set a readiness-probe timeout shorter than that.

This test’s own history ran into a real Liquibase 5.0.3 defect while it was being written. An earlier version tried to avoid a different race — two instances both trying to create DATABASECHANGELOG/DATABASECHANGELOGLOCK for the first time, which fails with a plain “table already exists” error rather than a graceful wait — by running a bootstrap update() against an empty changelog first. That bootstrap file was originally named master.yaml, same as the real changelog, just in a different directory. Doing that reliably reproduced a genuine defect: both real instances logged a completely normal “successful” summary, but the changeset’s own code never actually ran, and — checked from each instance’s own connection, no cross-connection visibility question involved — DATABASECHANGELOG stayed empty. A phantom success, caused by something in Liquibase’s changelog-history handling that keys off the changelog’s simple filename rather than its full resource path. Isolated by toggling only the bootstrap file’s name with everything else held constant: naming it master.yaml reproduced the phantom success on every run; naming it anything else — this module settled on bootstrap-only.yaml — never did, across dozens of runs. This is specific to constructing two changelogs with the same simple name from two different resource-accessor roots; an ordinary application with one changelog file never encounters it.

Full reproduction notes for that defect, plus the exact bytecode-decompilation steps for the lock settings above, are in chapter 11, Liquibase locking.

  • Going deeper: GlobalConfiguration lists every global Liquibase setting, including both lock-related ones above, with their system-property and environment-variable spellings.
  • If you hit a “table already exists” failure the very first time two instances of a brand-new service start against a brand-new database, this is why — it’s a one-time bootstrap race, not recurring lock contention, and a health-check retry (the same thing a rolling deploy already does for a failed pod) resolves it.

What Liquibase’s new license service actually gates

Flyway Community gates certain commands behind a runtime exception, as we saw earlier. Liquibase Community 5.0 shipped something new alongside its license change (the next section covers that change in full): a liquibase.license package, inside liquibase-core itself, that didn’t exist in 4.x. LiquibaseLicenseServiceTest doesn’t read about what that package does — it asks the actual service Spring Boot’s LiquibaseAutoConfiguration runs on top of, directly:

implementation: liquibase.license.OSSLicenseService
licenseIsInstalled(): false
licenseIsValid("any"): false
getLicenseInfo(): ""

(from docs/output/14-liquibase-oss-license-service.txt)

On the classpath this module uses — Community only, no Liquibase Pro artifact anywhere — the license service Liquibase actually wires up is OSSLicenseService, which reports itself as unlicensed and returns an empty license description. This test found no evidence of functional gating anywhere in the classpath this module runs on: update(), rollback(), and every changeset type exercised in this article worked identically whether or not a license was present, because none of them ever asked the license service anything. That’s a meaningfully different shape from Flyway’s stub-and-throw pattern from earlier — Liquibase Pro’s paid features apparently live in separate, additional artifacts rather than as gated stubs inside the Community jar itself.

More on why this new license service exists at all — and how it connects to the license change in the next section — is in chapter 12, The OSS license service.

  • Going deeper: this test is a snapshot of Liquibase 5.0.3’s Community classpath, not a guarantee about future releases — the license service’s existence at all, brand new in 5.0, is itself evidence that Liquibase’s commercial boundary is being actively redrawn.
  • Liquibase Pro’s feature comparison — what Pro actually adds, as separate functionality rather than unlocked stubs.

The FSL license change, and why it’s a live compliance question right now

This is the one section in this article with no test behind it — it’s not something you compile and run, it’s a licensing fact worth knowing before you mvn dependency:tree your way into it. It’s still verified against primary sources rather than paraphrased from a blog post, which is why every claim below links directly to where it came from.

Liquibase Community 5.0, the version bundled in the Spring Boot 4.1 BOM this article depends on (5.0.3), is the first release shipped under the Functional Source License (FSL-1.1-ALv2), effective September 30, 2025 — not the Apache License 2.0 every earlier Liquibase release used, and not an OSI-approved open source license at all. FSL is “source-available”: Liquibase’s own blog post announcing it says plainly that using, modifying and self-hosting Liquibase Community stays free, but a third party can’t “take Liquibase Community and commercialize it in a way that competes with Liquibase” — offering it as a managed service, specifically — during a two-year exclusivity window per release. The same post confirms the FSL’s built-in expiry: two years after each release, that release’s license automatically reverts to Apache 2.0.

Liquibase 5.0.3 released under FSL-1.1-ALv2 2 years later reverts to Apache 2.0, automatically Every future release restarts its own two-year clock — the version you pull today is FSL for those two years even after a later release has already converted to Apache 2.0.

This is not a hypothetical concern for the projects that depend on Liquibase. The Apache Software Foundation’s own Legal committee opened LEGAL-721 to evaluate whether ASF projects (Apache Fineract, specifically) can keep using FSL-licensed Liquibase 5 at all, given the ASF’s policy against bundling non-Apache-compatible licenses. Separately, Keycloak opened issue #43391 covering the same problem from the CNCF’s side: the CNCF’s own source-available policy — quoted directly in that issue as “CNCF does not permit source available licenses, and an exception is unlikely to be granted” — makes continuing to depend on FSL-licensed Liquibase a real compliance question for a CNCF project. The options that thread lays out are exactly what you’d expect: fork the last Apache-2.0-licensed 4.x release and maintain it independently, apply Liquibase only at build time and run the generated DDL through custom tooling at runtime, or switch to a still-fully-open-source alternative — the thread names Flyway specifically, noting that Dependency-Track already made that switch for this reason. No final decision had been recorded in that discussion as of this writing.

Nothing changes for ordinary use. Running spring-boot-starter-liquibase in your own application, including in production, is exactly what the FSL permits — the restriction targets competitors reselling Liquibase itself as a hosted product, not teams using it to manage their own schema. The two things actually worth tracking: whether your organization’s license-compliance process treats “source-available” the same as “open source” (many do not, by policy, regardless of what the license permits in practice), and whether a security or dependency audit flags a non-OSI license where it previously saw Apache 2.0.

Chapter 13, The FSL license change has the complete writeup with every citation above expanded.

Running both tools against one database

Every “Flyway or Liquibase” thread eventually gets a comment asking whether you can just run both — maybe one team owns a legacy Liquibase changelog and another is standardizing new services on Flyway, and for a while both point at the same database. BothTogetherTest answers it directly: enable both starters against the same DataSource, with nothing else configured, and see what actually happens on startup.

context failed to start: FlywayException: Found non-empty schema(s) "PUBLIC" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.

(from docs/output/15-both-together-same-datasource.txt)

In this configuration, Spring Boot wires Liquibase’s bean before Flyway’s migration initializer runs — an artifact of bean registration order here, not a documented contract to rely on. By the time Flyway gets its turn, Liquibase has already created a product table and its own tracking tables. Flyway looks at a non-empty schema with no flyway_schema_history table and does exactly what the baselining section earlier said it would do the first time it meets an existing database: it refuses to guess, and startup fails.

one DataSource 1. Liquibase runs first — creates PRODUCT, DATABASECHANGELOG and its lock table 2. Flyway runs second — sees unknown tables, no history table → startup fails The safety check that protected you earlier (a real database it didn’t build yet) fires here too — it can’t tell “existing production schema” apart from “Liquibase got here first.”

Turning on baseline-on-migrate alone isn’t enough — its default baseline-version of 1 tells Flyway “pretend V1 already ran,” correct only if the existing schema already matches what V1 would have built. Here it doesn’t: Liquibase’s changelog built an unrelated product table, not customer. Setting baseline-version=0 instead tells Flyway “nothing of mine has run, apply my whole migration set from scratch,” which is what actually needs to happen alongside whatever Liquibase already put there:

TABLE_NAME
---------------------
CUSTOMER
DATABASECHANGELOG
DATABASECHANGELOGLOCK
PRODUCT
flyway_schema_history

-- flyway_schema_history — baseline row at 0, then V1/V2 ran for real --
version | description                               | type     | success
--------+-------------------------------------------+----------+--------
NULL    | << Flyway Schema History table created >> | TABLE    | true
0       | << Flyway Baseline >>                     | BASELINE | true
1       | create customer                           | SQL      | true
2       | seed customer                             | SQL      | true

-- databasechangelog — Liquibase's own bookkeeping, untouched by Flyway --
ID               | AUTHOR | EXECTYPE
-----------------+--------+---------
1-create-product | ankurm | EXECUTED
2-seed-product   | ankurm | EXECUTED

(from docs/output/15-both-together-same-datasource.txt)

Both tools end up with their own tracking table, each accounting only for what it built — customer under Flyway, product under Liquibase — and neither aware the other exists. That’s the real shape of “running both”: not integration, just two independent bookkeepers sharing a schema, each blind to the other’s tables. You can reproduce this exact split yourself — the companion project’s both-naive and both-fixed Spring profiles fail and succeed exactly as shown above. Full mechanism is in chapter 14, Running both at once.

  • Going deeper: this module never asked Flyway or Liquibase to migrate the same table — the moment two tools try to own the same object, you’re outside anything either one tests for, and keeping them apart by naming or ownership convention becomes your migration authors’ job.
  • Spring Boot’s own note on combining Flyway and Liquibase confirms both can be enabled together but documents no execution order between them, matching what this test observed rather than any spec.

Which one should you actually pick

Neither tool is “safer” in the abstract; they fail differently, and this article reproduced both failure shapes directly rather than describing them secondhand. Flyway’s version-ordered, checksum-validated model is easy to reason about, and its Community edition simply has no working rollback — plan every migration as forward-only from day one, and budget for writing a compensating migration instead of an undo when something needs reversing. Liquibase’s changeset model supports real rollbacks, but only for the change types it knows how to invert on its own, and its 5.0 license change is a real, if narrow, compliance question some organizations will need to route through their own process before adopting it fresh. Running both together is a workable bridge during a migration between them — never a permanent architecture. Pick one, own it, and use the companion project to see exactly what “own it” has to account for.

A short, concrete checklist, built from what this article actually reproduced rather than generic advice: never run unquoted queries against Flyway’s tracking table on H2 (case-folding); a checksum mismatch fails startup, full stop, before any SQL runs; out-of-order migrations fail the whole startup by default, not just the late one; baselineVersion defaults to 1, meaning “assume the existing schema already matches V1” — get it wrong and Flyway either re-runs or skips work incorrectly; Flyway Community’s undo throws at runtime despite compiling fine; Liquibase can only auto-generate a rollback for structural changes, never for data; Liquibase’s default lock-poll rate is 10 seconds, worth checking against your readiness-probe timeout; Liquibase Community 5.0 ships under the FSL, not Apache 2.0; and running both tools against one database means two independent bookkeepers, not integration. The full, expanded version of this list, plus a small diagnostic endpoint this module built specifically to check a live server’s real state against either tool’s own claims, is in chapter 15, Production checklist — delete that endpoint before you ship anything real, its own Javadoc says so in the first line.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.