Database Migrations with Flyway
Schema changes you can deploy safely: Flyway naming and ordering, repeatable migrations, baselining an existing database, and expand-and-contract for zero downtime.
On this page
A schema change is the one part of a deploy that cannot be rolled back by redeploying the previous image. Flyway makes migrations versioned, ordered and verifiable — which is most of the problem — and leaves you the harder question of making each change safe to deploy.
Key Takeaways
- Migrations are immutable once applied. Fix forward with a new file, never edit an old one.
Vruns once in order;Rre-runs whenever its checksum changes.- Baseline to adopt Flyway on an existing database.
- Every change must be backwards compatible, because old and new code run together.
- Never enable
cleanoutside local development.
Naming and ordering
src/main/resources/db/migration/
├── V1__create_orders_table.sql
├── V2__add_customer_index.sql
├── V3.1__add_currency_column.sql
├── R__order_summary_view.sql # re-applied when its content changes
└── R__seed_reference_data.sqlThe pattern is prefix, version, double underscore, description. V migrations apply once in version
order and are recorded with a checksum. R migrations have no version and re-run whenever their
content changes — ideal for views, functions and idempotent seed data, which you would otherwise have
to version every time they change.
Use a timestamp version (V20260726103000__) on a team of any size. Sequential integers collide
constantly when two branches both add V7, and the resulting merge is more annoying than the longer
filename.
spring:
flyway:
enabled: true
locations: 'classpath:db/migration'
baseline-on-migrate: true
baseline-version: 1
validate-on-migrate: true
# Handles the branch case: a migration numbered below one already applied
# is still applied rather than silently skipped.
out-of-order: false
# NEVER true outside a local machine. It drops every object in the schema.
clean-disabled: true
jpa:
hibernate:
# Flyway owns the schema. ddl-auto would fight it and win unpredictably.
ddl-auto: validateddl-auto: validate is important. With update, Hibernate alters the schema based on entity
mappings, which means your schema is defined in two places that will eventually disagree. validate
makes Hibernate check the mapping against the schema and fail at startup if they differ — which is
exactly the feedback you want.
Writing a migration
-- Nullable with a default, so this can be applied while the old code is
-- still running and knows nothing about the column.
ALTER TABLE orders
ADD COLUMN currency VARCHAR(3) DEFAULT 'EUR';
-- Backfill in batches. A single UPDATE across ten million rows takes a long
-- lock and can block the application for the duration.
UPDATE orders SET currency = 'EUR' WHERE currency IS NULL;
-- CONCURRENTLY avoids an exclusive lock on Postgres. It cannot run inside a
-- transaction, hence the Flyway directive below.
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_orders_currency ON orders (currency);-- flyway:executeInTransaction=falseLong-running DDL is where migrations cause outages. ALTER TABLE ... ADD COLUMN with a default is
fast on modern PostgreSQL and rewrites the whole table on older versions and on MySQL. Adding an
index without CONCURRENTLY takes a lock that blocks writes for the duration. Check what your engine
and version actually do before assuming a statement is cheap.
Zero-downtime schema change
The reason this takes four releases is that a rolling deploy runs both versions at once. If release 1 dropped the old column, every pod still running the old code would fail on its next query. The sequence above guarantees that at every point, both the currently-deployed and the about-to-be-deployed versions work against the current schema.
The discipline that makes it manageable: never combine a schema change and a code change that depends on it in the same release.
It is worth being clear that this is not a rollback strategy, because there is no good one. Flyway's undo migrations are a paid feature, and even with them most schema changes cannot genuinely be reversed — a dropped column takes its data with it, and a backfill cannot be un-backfilled. Plan to fix forward. What expand-and-contract actually buys you is a sequence of states each of which is safe to stop at, which is a more useful property than a reverse button that would not work.
Running migrations separately
For anything slow, take migration out of application startup:
apiVersion: batch/v1
kind: Job
metadata:
name: order-service-migrate
annotations:
# Argo CD runs this before the Deployment is updated.
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: flyway
image: ghcr.io/acme/order-service:${IMAGE_TAG}
args: ['--spring.main.web-application-type=none', '--app.migrate-only=true']The application image runs with web disabled, applies migrations, and exits. Only then does the Deployment roll. This avoids a slow migration blocking startup past the readiness deadline, which otherwise gets the pod killed mid-migration — a genuinely unpleasant state to recover from.
Flyway takes a database lock, so concurrent instances are safe, but the first one still blocks the others until it finishes.
Callbacks and Java migrations
public class V5__anonymise_legacy_customers extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
try (var statement = context.getConnection().createStatement()) {
// Java migrations suit transformations that need real logic —
// parsing, hashing, calling a library — which SQL cannot express.
var rows = statement.executeQuery(
"SELECT id, email FROM customers WHERE anonymised = false LIMIT 10000");
// ... batched processing
}
}
}Use Java migrations sparingly and only where SQL genuinely cannot do the job. They are harder to review, harder to run by hand during an incident, and their checksum depends on compiled bytecode.
Testing migrations
@Test
void migrationsApplyCleanlyFromScratch() {
try (var postgres = new PostgreSQLContainer<>("postgres:16-alpine")) {
postgres.start();
var result = Flyway.configure()
.dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
.locations("classpath:db/migration")
.load()
.migrate();
assertThat(result.success).isTrue();
assertThat(result.migrationsExecuted).isGreaterThan(0);
}
}Run this against the same engine and major version as production. A migration that works on H2 and fails on PostgreSQL is a deployment that fails at the worst moment, and the syntax differences are exactly in the areas migrations use most.
What to take away
Version migrations with timestamps, never edit an applied one, and let Flyway own the schema with
ddl-auto: validate. Make every change backwards compatible using expand-and-contract across several
releases. Run slow migrations as a separate job, and test them against a real database of the right
version.
Frequently Asked Questions
Why did my build fail with a checksum mismatch?
How do I adopt Flyway on an existing database?
Should migrations run at application startup?
Related tutorials
- Production-Grade Application ConfigurationConfiguration that survives production: the 12-factor principles applied to Java, property precedence, fail-fast validation, feature flags, and graceful shutdown done properly.
- Performance Tuning & JVM OptimisationDiagnosing and fixing JVM performance: the memory model, choosing and tuning a collector, reading GC logs, profiling with JFR and async-profiler, and container-aware settings.
- Production Observability — Full StackAssembling a production observability stack: the OTel agent and collector pipelines, Mimir, Loki and Tempo, alerting strategy that avoids fatigue, and runbooks that get used.
- Load Testing & Capacity PlanningFinding your limits before users do: the five load test types, writing k6 and Gatling scenarios, the metrics that matter, and turning results into a capacity plan.