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 viaspring-boot-starter-flywayandspring-boot-starter-liquibase. Runningmvn dependency:treeagainst 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.
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, soselect * from flyway_schema_historyrun from your own code looks forFLYWAY_SCHEMA_HISTORYand 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 forDATABASECHANGELOGfolds 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.
- Going deeper: Flyway’s full migration naming grammar — versioned, repeatable and undo prefixes in one place.
- H2’s identifier case-sensitivity rules — this module runs on H2’s default, which is what produces the quoting trap above.
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.
- Going deeper: spring.flyway.validate-on-migrate in Spring Boot’s configuration reference — true by default.
- Flyway’s repair command — a deliberate, logged action, not something safe to script into a startup hook.
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)
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:
outOfOrderis 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
outOfOrderat 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 whateverspring.flyway.baseline-descriptionwas 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, typeTABLE), 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=truetriggers 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)
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
NoSuchMethodErroror 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.
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.
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 aCommandScope/UpdateCommandStepimplementation 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)
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 for —
createTable,addColumnand a handful of other structural changes; almost anything involving data needs an explicitrollback:block. - Liquibase also supports
rollbackCount,rollbackToDateand rolling back by tag — this module exercises only the single-changesetrollback(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)
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 createDATABASECHANGELOG/DATABASECHANGELOGLOCKfor the first time, which fails with a plain “table already exists” error rather than a graceful wait — by running a bootstrapupdate()against an empty changelog first. That bootstrap file was originally namedmaster.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 —DATABASECHANGELOGstayed 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 itmaster.yamlreproduced the phantom success on every run; naming it anything else — this module settled onbootstrap-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.
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.
- Going deeper: FSL-1.1-ALv2 license text — the license itself, including the exact terms of the two-year Apache 2.0 conversion.
- Liquibase GitHub issue #7382 — Liquibase’s own tracking issue for updating “open source” language across their codebase and docs to match the new license.
- Liquibase 4.x support policy discussion, issue #7375 — whether the last Apache-2.0 major version keeps receiving fixes, directly relevant to the “fork 4.x” option above.
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.
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
- db-migrations-flyway-liquibase — the full companion project: 15 tests, 15 documentation chapters, and every transcript quoted in this article.
- Flyway configuration reference — every property this project exercised, plus the ones it didn’t.
- Liquibase configuration reference — including the lock-related settings this article measured.
- Spring Boot’s database initialization reference — the current, canonical source for how Boot 4.1 wires up both tools.
- Deploying Spring Boot 4 on Kubernetes — if the readiness-probe timing questions this article raised are new to you, that article covers probe behavior under real dependency outages.
- Spring Boot Actuator in Production — for locking down the
/actuator/flywayand/actuator/liquibaseendpoints this article’s diagnostic controller sits alongside.
No Comments yet!