Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Beginner5 min readUpdated
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.
  • V runs once in order; R re-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 clean outside 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.sql

The 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.

application.yml
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: validate

ddl-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

V4__add_order_currency.sql
-- 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);
V4__add_order_currency.sql — header for Postgres
-- flyway:executeInTransaction=false

Long-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

Four releases to rename a column safely. Doing it in one breaks whichever version is not yet deployed everywhere.

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:

migration-job.yaml
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

V5__anonymise_legacy_customers.java
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

MigrationTest.java
@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?
Someone edited a migration that had already been applied. Flyway stores a checksum per applied migration and refuses to continue when it changes, because the database no longer matches the script. The fix is to add a new migration, never to edit an applied one. Use flyway repair only when the change was genuinely cosmetic.
How do I adopt Flyway on an existing database?
Set baselineOnMigrate to true and baselineVersion to 1. Flyway records the current state as version 1 and applies only migrations numbered above it. Generate a V1 script from the current schema for reference so a fresh environment can be built from scratch.
Should migrations run at application startup?
Fine for a single instance. With several replicas starting together they race, though Flyway takes a lock so only one wins. The bigger problem is that a slow migration blocks startup past your readiness deadline. For large changes, run migrations as a separate job before the deployment.

Related tutorials