Files
asmhatreandClaude Sonnet 5 3908331431 Add db-migrations-flyway-liquibase: Flyway vs Liquibase migrations, rollbacks and baselines on Spring Boot 4.1
Companion code for the Flyway vs Liquibase article: checksum validation, out-of-order
and repeatable migrations, baselining an existing schema, Flyway Community's undo/diff/deploy
stubs, concurrent-startup locking for both tools, Liquibase changeset identity and rollback
(auto-generated vs explicit), a verified Liquibase 5.0.3 filename-caching defect, the new
OSS license service, the FSL license change, and running both tools against one database.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q6XdRjtsp4862EM44T7i9a
2026-09-15 07:08:57 +00:00

94 lines
6.0 KiB
Markdown

# 14. Running both at once
[← 13. The FSL license change](13-the-fsl-license-change.md) · [Index](../README.md) · Next: [15. Production checklist →](15-production-checklist.md)
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`](../src/test/java/com/ankurm/dbmigrations/BothTogetherTest.java) answers it
directly: enable both starters against the same `DataSource`, with nothing else configured, and
see what actually happens on startup.
## The naive answer: it refuses to start
```
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`](output/15-both-together-same-datasource.txt))
In this configuration, Spring Boot wires Liquibase's `SpringLiquibase` bean before Flyway's
`FlywayMigrationInitializer` runs — an artifact of bean registration order here, not a documented
contract you should rely on. By the time Flyway gets its turn, Liquibase has already created a
`product` table and its own `DATABASECHANGELOG`/`DATABASECHANGELOGLOCK` tables. Flyway looks at a
non-empty schema with no `flyway_schema_history` table and does exactly what chapter
[6](06-baselining-an-existing-database.md) said it would do the first time it meets an existing
database: it refuses to guess, and startup fails.
<svg viewBox="0 0 700 170" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<text x="20" y="20">one DataSource</text>
<rect x="20" y="35" width="300" height="50" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="170" y="55" text-anchor="middle">1. Liquibase runs first</text>
<text x="170" y="72" text-anchor="middle" font-size="11">creates PRODUCT, DATABASECHANGELOG*</text>
<path d="M320 60 L380 60" stroke="#334155" stroke-width="2" marker-end="url(#a7)"/>
<rect x="380" y="35" width="300" height="50" rx="6" fill="#fee2e2" stroke="#dc2626"/>
<text x="530" y="55" text-anchor="middle">2. Flyway runs second</text>
<text x="530" y="72" text-anchor="middle" font-size="11">sees unknown tables, no history &#8594; fails</text>
<text x="20" y="120" font-size="12" fill="#475569">Flyway's safety check that protected you in chapter 6 (a real database it didn't</text>
<text x="20" y="138" font-size="12" fill="#475569">build yet) fires here too &#8212; it can't tell "existing production schema" apart</text>
<text x="20" y="156" font-size="12" fill="#475569">from "Liquibase got here first". Same check, same message, different cause.</text>
<defs><marker id="a7" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
## The fix: tell Flyway nothing has run yet
Turning on `baseline-on-migrate` alone isn't enough. Its default `baseline-version` is `1`, which
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
(5 rows)
-- 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
(4 rows)
-- databasechangelog — Liquibase's own bookkeeping, untouched by Flyway --
ID | AUTHOR | EXECTYPE
-----------------+--------+---------
1-create-product | ankurm | EXECUTED
2-seed-product | ankurm | EXECUTED
(2 rows)
```
(from [`docs/output/15-both-together-same-datasource.txt`](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 one 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 locally:
[`application.yml`](../src/main/resources/application.yml)'s `both-naive` profile fails to start
the way the transcript above shows, and `both-fixed` starts cleanly with the baseline settings
already applied — run them with `./scripts/run.sh both-naive` and `./scripts/run.sh both-fixed`.
## 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 it becomes your migration author's job to keep them apart by convention (naming, schemas, or ownership documented somewhere a new teammate will actually read).
- The bean-ordering behavior that makes Liquibase run first here is not a documented contract — treat it as incidental, not as something to design a real system around. A real migration from one tool to the other should have a clean cutover point, not a permanent "both, forever" steady state.
- [Spring Boot's own note on combining Flyway and Liquibase](https://docs.spring.io/spring-boot/reference/data/sql.html) (`rel="nofollow"`) — Boot's docs confirm both can be enabled together but don't document an execution order between them, which matches what this test observed rather than what any spec promises.