Database Migration with Flyway in Spring Boot

1. Overview

As your application evolves, your database schema must evolve too. If you manually run each ALTER TABLE statement on every environment, dev, staging, and production will drift apart sooner or later. Flyway solves this: each schema change is a versioned migration file, applied automatically in the correct order.

In this article, you’ll configure Flyway for a Spring Boot 3 application, write migrations in both SQL and Java, test them with JUnit 5, and learn how to handle common errors like checksum mismatch.

2. Installing Dependencies

Spring Boot already manages the Flyway version for you, so you don’t need to specify it. The example below uses PostgreSQL:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-database-postgresql</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

From Flyway 10 onwards (bundled with Spring Boot 3.3+), support for each database type is split into separate modules. If you’re missing flyway-database-postgresql, the application won’t start and will report an error:

org.flywaydb.core.api.FlywayException: Unsupported Database: PostgreSQL 16.4

H2 is still bundled in flyway-core, so the test side doesn’t need any additional modules.

3. Basic Configuration

With just flyway-core on the classpath, Spring Boot automatically creates a Flyway bean and runs migrations before EntityManagerFactory is initialized. Minimal configuration in application.properties:

spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret

spring.jpa.hibernate.ddl-auto=validate

spring.flyway.locations=classpath:db/migration
spring.flyway.validate-on-migrate=true

Key lines:

  • spring.jpa.hibernate.ddl-auto=validate: Hibernate only checks if entities match the schema. It doesn’t modify the schema. Flyway is the single source of truth for your schema.
  • spring.flyway.locations: directory containing migrations. The default is already classpath:db/migration. We specify it here for clarity.
  • spring.flyway.validate-on-migrate=true: before migrating, Flyway validates the checksum of previously run migrations. This is also the default value.

Note: Don’t use ddl-auto=update or create together with Flyway. Hibernate and Flyway will modify the schema simultaneously, and the result is unpredictable.

4. Writing SQL Migrations

4.1. Naming Convention

Flyway recognizes migrations by their file name:

V1__create_customer_table.sql
│ │  └── description (underscores represent spaces)
│ └── two underscores as separator
└── V = versioned migration, followed by version number

Versions can be 1, 2, 1.1, or 20261016.1. Flyway compares versions by numeric value, not alphabetically. So V10 runs after V9.

4.2. Your First Migration

Create the file src/main/resources/db/migration/V1__create_customer_table.sql:

CREATE TABLE customer (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    full_name  VARCHAR(100) NOT NULL,
    created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Next comes V2__create_orders_table.sql:

CREATE TABLE orders (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    customer_id BIGINT         NOT NULL REFERENCES customer (id),
    total       NUMERIC(12, 2) NOT NULL,
    status      VARCHAR(20)    NOT NULL
);

CREATE INDEX idx_orders_customer_id ON orders (customer_id);

When you start the application, the log will look like:

Flyway Community Edition 10.x by Redgate
Database: jdbc:postgresql://localhost:5432/shop (PostgreSQL 16.4)
Successfully validated 2 migrations (execution time 00:00.012s)
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "1 - create customer table"
Migrating schema "public" to version "2 - create orders table"
Successfully applied 2 migrations to schema "public", now at version v2

4.3. The flyway_schema_history Table

Flyway records every applied migration in the flyway_schema_history table. You can query it directly:

SELECT installed_rank, version, description, checksum, success
FROM flyway_schema_history
ORDER BY installed_rank;
 installed_rank | version |      description      |  checksum   | success
----------------+---------+-----------------------+-------------+---------
              1 | 1       | create customer table | -1253721480 | t
              2 | 2       | create orders table   |   873416502 | t

On the next startup, Flyway sees the schema is already at version 2 and doesn’t re-run anything. The checksum column is key to section 7.

4.4. Repeatable Migrations

Files starting with R__ are repeatable migrations. They have no version. Flyway re-runs such files whenever their checksum changes, and always runs after all versioned migrations. This is perfect for views, functions, or stored procedures:

-- R__order_summary_view.sql
CREATE OR REPLACE VIEW order_summary AS
SELECT c.id AS customer_id, c.full_name, COUNT(o.id) AS order_count
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.full_name;

5. Java Migrations

Some changes are hard to express in pure SQL, like backfilling data with string-processing logic. In such cases, write a Java migration by extending BaseJavaMigration.

First, add the column in SQL with V3__add_customer_email.sql:

ALTER TABLE customer ADD COLUMN email VARCHAR(150);

Then put this class in the db.migration package at src/main/java/db/migration/V4__Backfill_customer_email.java:

package db.migration;

import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.SingleConnectionDataSource;

import java.text.Normalizer;

public class V4__Backfill_customer_email extends BaseJavaMigration {

    @Override
    public void migrate(Context context) {
        var jdbc = new JdbcTemplate(
                new SingleConnectionDataSource(context.getConnection(), true));

        jdbc.query("SELECT id, full_name FROM customer WHERE email IS NULL", rs -> {
            long id = rs.getLong("id");
            String email = toSlug(rs.getString("full_name")) + "." + id + "@example.com";
            jdbc.update("UPDATE customer SET email = ? WHERE id = ?", email, id);
        });
    }

    private static String toSlug(String name) {
        String noAccent = Normalizer.normalize(name, Normalizer.Form.NFD)
                .replaceAll("\\p{M}", "")
                .replace('đ', 'd').replace('Đ', 'D');
        return noAccent.toLowerCase().trim().replaceAll("\\s+", ".");
    }
}

Explanation:

  • Class name follows the same convention as SQL files: V4__Backfill_customer_email.
  • context.getConnection() returns the connection Flyway is using, within the same migration transaction. The true parameter to SingleConnectionDataSource prevents JdbcTemplate from closing this connection.
  • Flyway scans the db.migration package because it corresponds to classpath:db/migration.

For a customer named Nguyễn Văn An with id 1, the result is nguyen.van.an.1@example.com.

Spring Boot also auto-registers any JavaMigration bean with Flyway. You can declare migrations as @Component. However, you shouldn’t inject repositories or services into migrations. Business logic changes over time, but migrations must run identically forever.

6. Testing Migrations

You should have at least one test confirming all migrations run successfully from an empty schema and Hibernate validation passes. Create src/test/resources/application-test.properties:

spring.datasource.url=jdbc:h2:mem:shop;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=

Test:

import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class FlywayMigrationTest {

    @Autowired
    private Flyway flyway;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void whenContextStarts_thenAllMigrationsAreApplied() {
        var current = flyway.info().current();

        assertThat(current).isNotNull();
        assertThat(current.getVersion().getVersion()).isEqualTo("4");
        assertThat(flyway.info().pending()).isEmpty();
    }

    @Test
    void givenNewCustomer_whenInsert_thenEmailColumnExists() {
        jdbcTemplate.update(
                "INSERT INTO customer (full_name, email) VALUES (?, ?)",
                "Trần Thị Bình", "binh@example.com");

        String email = jdbcTemplate.queryForObject(
                "SELECT email FROM customer WHERE full_name = ?",
                String.class, "Trần Thị Bình");

        assertThat(email).isEqualTo("binh@example.com");
    }
}

The first test verifies the schema is at version 4 and no migrations are pending. If a SQL file has syntax errors, the context won’t start and the test fails immediately. The second test confirms the email column from V3 actually exists.

Note: H2 in MODE=PostgreSQL only emulates a subset of PostgreSQL syntax. If your migration uses PostgreSQL-specific features like JSONB, partial indexes, or CREATE INDEX CONCURRENTLY, run tests on real PostgreSQL using Testcontainers.

7. Common Errors and How to Handle Them

7.1. Modifying an Already-Applied Migration

This is the most common mistake. Suppose you modify V2__create_orders_table.sql after it’s already run on staging. The next startup will report:

Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 2
-> Applied to database : 873416502
-> Resolved locally    : -402981177

The rule is: never modify a migration that has run on any shared environment. If you need to make changes, create a new migration, like V5__alter_orders_status_length.sql.

If the change is only comments or whitespace and you’re certain the schema hasn’t changed, you can update the checksum with the repair command. For example, with the Flyway Maven plugin:

mvn flyway:repair -Dflyway.url=jdbc:postgresql://localhost:5432/shop \
  -Dflyway.user=shop -Dflyway.password=secret

repair also removes failed migration records from flyway_schema_history. This is important with MySQL, since MySQL can’t rollback DDL within a transaction.

7.2. Duplicate Versions in Team Development

Two developers creating V5__... on different branches will get a Found more than one migration with version 5 error after merge. There are two ways to prevent this:

  • Use timestamp-based versions, like V20261016_0930__add_order_note.sql.
  • Agree on version “reservations” before creating files.

Another scenario: branch A merges V7 first, then branch B merges V6. Flyway will report V6 as ignored. You can enable spring.flyway.out-of-order=true, but it’s better to rename the new file to have a higher version than the current one.

7.3. Applying Flyway to an Existing Database

With a production database that has tables but no flyway_schema_history, Flyway will refuse to run because the schema is not empty. Use baseline:

spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1

Flyway marks the current schema as version 1. Then it only runs migrations with versions greater than 1. At this point, V1 should be a dump of your current schema, so new environments can be built from scratch.

Note: Only enable baseline-on-migrate during the initial migration. If you leave it on long-term and accidentally point to the wrong database with existing tables, Flyway will baseline it instead of reporting an error.

7.4. The clean Command

flyway clean deletes all objects in the schema. Spring Boot 3 sets spring.flyway.clean-disabled=true by default. Never disable this flag on any profile that might touch production.

7.5. Long-Running Migrations

Spring Boot runs migrations at startup. An ALTER TABLE on a table with tens of millions of rows can lock the table and cause health checks to fail. In such cases, you should:

  • Break changes into steps: add a nullable column, backfill in batches, then add NOT NULL.
  • Disable auto-migrate with spring.flyway.enabled=false and run Flyway as a separate step in your deploy pipeline, before rolling out the application.

8. When Not to Use Flyway in Its Default Mode

Flyway works well for most Spring Boot applications. However, the “migrate on startup” approach has limitations:

  • Multiple instances starting simultaneously: Flyway uses database locking to prevent duplicate migrations. But other instances must wait, increasing startup time.
  • Application account lacks DDL permissions: this is a common security requirement in production. Run Flyway with a separate account in your pipeline.
  • Need automatic rollback: the Community edition has no undo feature. You must write “forward-moving” migrations to undo changes.

9. Conclusion

In this article, you’ve integrated Flyway into Spring Boot 3. You’ve written versioned migrations, repeatable migrations, and Java migrations, tested them with JUnit 5 and AssertJ, and learned how to handle common errors like checksum mismatch, duplicate versions, and baselining old databases.

Three principles to remember:

  1. Flyway is the only place that changes the schema; Hibernate only validates.
  2. Never modify a migration that has already run.
  3. Always test running all migrations from an empty schema.
spring-bootflywaydatabasemigrationpostgresql