Database Migrations with Flyway¶
Your schema is code. It needs version control, review, and a repeatable way to get every environment — your laptop, CI, staging, production — to the same state. Flyway does this with plain SQL files applied in order, and Spring Boot runs it automatically at startup.
Setup¶
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<!-- For PostgreSQL, Flyway also needs its database module: -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
(In Boot 3 you add org.flywaydb:flyway-core directly instead of a starter. Since
Flyway 10, most databases other than H2 need their flyway-database-* module.)
Migrations live in src/main/resources/db/migration:
db/migration/
├── V1__create_authors_and_books.sql
├── V2__add_book_language.sql
└── V3__create_loans.sql
The name is V<version>__<description>.sql — capital V, two underscores. Versions
sort numerically (V2 < V10).
A first migration¶
-- V1__create_authors_and_books.sql
create table author (
id bigint generated by default as identity primary key,
name varchar(200) not null
);
create table book (
id bigint generated by default as identity primary key,
title varchar(300) not null,
isbn varchar(20) not null unique,
published integer,
author_id bigint not null references author(id),
version bigint not null default 0
);
create index idx_book_author on book(author_id);
Pair it with spring.jpa.hibernate.ddl-auto: validate: Flyway creates the schema;
Hibernate checks at startup that the entities match it. If someone adds a field to an
entity without a migration, the app refuses to start instead of failing on the first
query.
Worked example: what startup does¶
When this level's project starts on a fresh H2 database, the log shows (real lines, Flyway 12 with Boot 4.1):
o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" to version "1 - create authors and books"
o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "PUBLIC", now at version v1 (execution time 00:00.005s)
Start it again against a persistent database and Flyway applies nothing — it sees V1 is
already recorded. Add V2__add_book_language.sql:
and the next startup applies only V2.
The rules of the road¶
- Never edit an applied migration. Flyway stores a checksum of each applied file; a changed file fails validation at startup ("Migration checksum mismatch"). Fix mistakes with a new migration.
- One logical change per migration, with a descriptive name. Reviewers read these.
- Migrations must work on the production database engine. Test against the real engine (Testcontainers, next lesson); H2's SQL dialect differs from PostgreSQL's in many small ways.
- Data migrations are migrations too. Backfilling a column is a
Vfile with anupdatestatement — or, for huge tables, a batched job run separately. - Repeatable migrations (
R__views.sql) rerun whenever their content changes — useful for views and functions that you want to redefine as a whole.
Changing a schema without downtime¶
During a rolling deployment, old and new versions of your app run against the same database at the same time. So every migration must be compatible with both the current code and the next code. Renaming a column in one step breaks the old version immediately. Use expand–contract instead:
| Release | Migration | Code |
|---|---|---|
| 1 | Add full_name (nullable) |
Write both name and full_name; read name |
| 2 | Backfill full_name from name |
Read full_name |
| 3 | Make full_name not null |
Stop writing name |
| 4 | Drop name |
— |
It is slower, and it is how you rename things in a live system. Also watch for
migrations that lock large tables (adding a column with a volatile default, creating an
index without CONCURRENTLY in PostgreSQL); on big tables these can block writes for
minutes.
How It Actually Works¶
Boot's FlywayAutoConfiguration creates a Flyway bean configured from spring.flyway.*
and a FlywayMigrationInitializer that calls flyway.migrate() during context startup.
Boot also makes the JPA EntityManagerFactory depend on the Flyway initializer, so the
schema is migrated before Hibernate validates it — that ordering is why validate works.
migrate() itself:
- Takes a lock (on PostgreSQL, an advisory lock) so two instances starting at once do not both migrate.
- Creates
flyway_schema_historyif missing. Each row records version, description, script name, checksum (CRC32 of the file), who ran it, when, how long, and success. - Scans the classpath locations, sorts migrations by version, and validates the applied ones by checksum.
- Applies each pending migration, by default in its own transaction (PostgreSQL supports transactional DDL, so a failed migration rolls back cleanly; MySQL does not, so a half-applied migration there needs manual repair).
Common mistakes¶
- Editing V1 after it ran somewhere. Create V2.
- Mixing
ddl-auto: updatewith Flyway, so two tools fight over the schema. - Testing migrations only on H2. They pass, then fail on PostgreSQL in staging.
- Renaming or dropping columns in one release during rolling deploys.
- Running
flyway cleanagainst anything shared. It drops everything; Boot disables it by default (spring.flyway.clean-disabled=true) — keep it that way.
Exercise¶
- Add Flyway to your project with V1 above and switch to
ddl-auto: validate. - Add
V2__add_book_language.sqland alanguagefield on the entity. Confirm startup applies V2 once. Then remove the field from the entity only and observe that validation still passes (extra columns are allowed); remove it from the migration only and observe the failure. - Edit V1 after it has been applied to a file-based H2 or PostgreSQL database and read the checksum error.
- Plan (in writing) an expand–contract rename of
book.publishedtobook.published_year, listing the migrations and code changes per release.