Flyway: Schema Changes You Can Deploy on a Friday
Versioned migrations, why ddl-auto must never run in production, and the expand-contract pattern that makes a column rename safe under rolling deployment.
The Schema Is Code
Application code is versioned, reviewed, and deployed through a pipeline. The schema it depends on is frequently none of those — changed by hand in a GUI, applied to staging and forgotten in production, with no record of what ran or in what order.
Migrations close that gap: every schema change is a file in the repository, applied in order, recorded, and identical across environments. Flyway is the default in Spring Boot and needs almost no configuration.
Setup
dependencies {
implementation 'org.flywaydb:flyway-core'
// Postgres, MySQL and a few others need their own module in Flyway 10+
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
}That's it. Flyway on the classpath means Spring Boot runs migrations at startup, before the EntityManagerFactory is created — so JPA always sees the migrated schema.
Migrations live in src/main/resources/db/migration:
db/migration
├── V1__create_orders.sql
├── V2__add_order_status.sql
├── V3__create_order_lines.sql
└── R__order_summary_view.sql
The naming is strict and worth getting right:
| Prefix | Kind | When it runs |
|---|---|---|
V | Versioned | Once, in version order |
R | Repeatable | Whenever its checksum changes, after all versioned |
U | Undo | Only on explicit undo (a paid Flyway feature) |
V2__add_order_status.sql is version 2, description add order status — two underscores separate them. One underscore, and Flyway won't recognise the file.
Writing a Migration
-- V1__create_orders.sql
create table orders (
id bigserial primary key,
customer_name varchar(100) not null,
total numeric(19, 2) not null,
status varchar(20) not null,
placed_at timestamptz not null default now()
);
create index idx_orders_status on orders (status);
create index idx_orders_placed_at on orders (placed_at desc);-- V2__add_order_reference.sql
alter table orders add column reference varchar(20);
update orders set reference = 'LEGACY-' || id where reference is null;
alter table orders alter column reference set not null;
create unique index idx_orders_reference on orders (reference);Note the three-step shape in V2: add the column nullable, backfill it, then add the constraint. Adding a not null column to a populated table fails without a default — and a default on a large table can rewrite it. Backfilling explicitly is both safer and visible in review.
Never edit a migration that has already run. Flyway records a checksum per applied migration and refuses to start when one changes: "Migration checksum mismatch for migration version 2". This is the feature working — an edited migration means your environments have silently diverged, with production carrying the old version and fresh databases getting the new one. Fix forward with V3__, always.
The Schema History Table
Flyway maintains flyway_schema_history:
| installed_rank | version | description | checksum | success |
|---|---|---|---|---|
| 1 | 1 | create orders | 1038745632 | true |
| 2 | 2 | add order reference | -884210033 | true |
On each startup Flyway reads this table, compares it to the files on the classpath, and applies what's missing in order. That's the entire mechanism.
Two situations where you'll interact with it directly:
An existing database with no Flyway history. Baseline it:
spring:
flyway:
baseline-on-migrate: true
baseline-version: 1 # treat the current schema as version 1Flyway writes a baseline row and applies only migrations above that version. Useful exactly once, when adopting Flyway on a live system — then remove it, because leaving it on means a genuinely empty database gets baselined rather than built.
A failed migration. On a database without transactional DDL (MySQL), a half-applied migration leaves a success = false row and Flyway refuses to proceed. You repair the history (flyway repair) after manually resolving the partial change. Postgres has transactional DDL, so a failed migration rolls back cleanly — one of several reasons it's the easier database to operate.
ddl-auto: Not in Production
Hibernate can generate the schema from your entities:
spring:
jpa:
hibernate:
ddl-auto: update # ← never in production| Value | Effect |
|---|---|
none | Nothing. Correct for production |
validate | Verify entities match the schema; fail if not |
update | Add missing tables and columns |
create | Drop and recreate at startup |
create-drop | Create at startup, drop at shutdown |
update is the dangerous one, because it mostly works. What it doesn't do: drop removed columns, change a column's type, rename anything, add an index, or tell you what it did. It has no record and no ordering, so two environments that received changes in a different sequence end up differently shaped with nothing to compare.
The right configuration alongside Flyway:
spring:
jpa:
hibernate:
ddl-auto: validate # Flyway owns the schema; fail fast on driftvalidate is actively useful — it catches the mismatch between an entity and a migration at startup rather than on the first query that touches the missing column.
create-drop is genuinely right for tests, where a disposable schema per run is what you want. Even there, running the real migrations against a Testcontainers Postgres is better: it tests your migrations as well as your code, and catches the dialect differences that H2 hides.
Zero-Downtime Migrations
The part that turns migrations from bookkeeping into a deployment skill.
During a rolling deployment, old and new application code run simultaneously against one database. So a migration must be compatible with the version of the code that is still running.
| Change | Safe? |
|---|---|
| Add a nullable column | Yes |
| Add a table | Yes |
| Add an index (concurrently) | Yes |
Add a not null column with a default | Usually — can rewrite a large table |
| Drop a column | No — old code still selects it |
| Rename a column | No — breaks both directions |
| Narrow a type or add a constraint | No — existing writes may violate it |
Expand and contract
A rename, done safely, is three deployments:
1. Expand. Add the new column; write to both.
-- V5__add_customer_full_name.sql
alter table orders add column customer_full_name varchar(200);public void setCustomerName(String name) {
this.customerName = name; // old column
this.customerFullName = name; // new column
}2. Migrate and switch. Backfill, then read from the new column. Old code is gone by now.
-- V6__backfill_customer_full_name.sql
update orders set customer_full_name = customer_name where customer_full_name is null;3. Contract. Once nothing references the old column, drop it.
-- V7__drop_customer_name.sql
alter table orders drop column customer_name;Three deploys to rename a column feels heavy. It is also the difference between a routine change and a maintenance window — and the alternative isn't "one deploy", it's "one deploy plus an incident".
On Postgres, create index takes a lock that blocks writes for the duration — on a large table, minutes of blocked writes. Use create index concurrently, which doesn't. It cannot run inside a transaction, so tell Flyway not to wrap it:
-- V8__add_orders_customer_index.sql
-- flyway:executeInTransaction=false
create index concurrently idx_orders_customer on orders (customer_full_name);Check yourself
A migration renaming customer_name to customer_full_name in one statement is deployed with a rolling update. What happens?
Testing Migrations
Migrations are code and deserve a test. The highest-value one runs them from empty against a real database:
@SpringBootTest
@Testcontainers
class MigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
@Test
void migrationsApplyCleanly() {
// Context startup ran Flyway. Reaching here means every migration applied.
}
}@ServiceConnection wires the container's URL and credentials into the context automatically — no property juggling.
This catches a surprising amount: syntax errors, ordering mistakes, a migration that assumed data only present in staging. And because it uses the same Postgres version as production, it catches the dialect gaps that H2 papers over.
Testing against H2 while running Postgres in production is a persistent source of late surprises — jsonb, on conflict, window functions, array types and even casting rules differ. Testcontainers makes the real thing cheap enough that H2's main advantage has mostly evaporated.
Useful Configuration
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false # true only when adopting on an existing DB
validate-on-migrate: true # checksum verification — keep on
out-of-order: false # reject a lower version appearing later
clean-disabled: true # see belowclean-disabled: true matters. flyway clean drops every object in the schema; it is the default in Flyway 10+ for good reason, and there is no scenario in which you want it reachable from a production configuration.
out-of-order: false is the default and usually right, but it has a real consequence for teams: two developers branching from V5 both write V6, and the second to merge must renumber. Timestamp-style versions (V20261009120000__) avoid the collision at the cost of readability.
The Mental Model, Restated
- The schema is code — versioned, reviewed, deployed in order.
V1__name.sql, two underscores, applied once and checksummed.- Never edit an applied migration. Fix forward.
ddl-auto: validatein production, neverupdate. Flyway owns the schema.- Old and new code run together during a rollout — so additive changes are safe and renames and drops are not.
- Expand-contract for renames: add and dual-write, backfill and switch, then drop.
create index concurrentlywithexecuteInTransaction=false.- Test migrations from empty against a real database with Testcontainers.
What's Next
With the schema under control and queries tuned, the next lever is not running the query at all. The next guide covers Spring's caching abstraction — @Cacheable and friends, the cache-aside pattern, swapping an in-memory cache for Redis without touching application code, and the invalidation problems that make caching harder than it looks.