Flyway: Database Migrations, Versioned Scripts & Spring Boot Integration

Database schemas evolve continuously: tables are added, columns renamed, indexes created. Without a migration tool you end up with manual SQL scripts, environment drift, and painful rollbacks. Flyway solves this by giving every schema change a version number, tracking which scripts have run, and guaranteeing that every environment reaches exactly the same schema state. Spring Boot auto-configures Flyway on startup with zero boilerplate.

Contents

Dependency & Auto-Configuration

Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.

XML
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-core</artifactId>
</dependency>
<!-- For MySQL / MariaDB -->
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-mysql</artifactId>
</dependency>
<!-- For SQL Server -->
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-sqlserver</artifactId>
</dependency>

Spring Boot auto-detects Flyway on the classpath and runs all pending migrations before the application context finishes starting. No @EnableFlyway annotation is needed.

YAML
# application.yml — minimal config (datasource is all that's required)
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: app
    password: secret
  flyway:
    enabled: true                      # default — set false to disable
    locations: classpath:db/migration  # default script location
    baseline-on-migrate: false         # set true for existing databases (see Baseline section)

Script Naming Convention

Flyway identifies scripts by their filename. The naming pattern determines how each script is treated.

PrefixPatternExampleBehaviour
VV{version}__{description}.sqlV1__create_users.sqlRuns once, tracked in flyway_schema_history
UU{version}__{description}.sqlU1__undo_create_users.sqlUndo script (Flyway Teams)
RR__{description}.sqlR__seed_lookup.sqlRe-runs when checksum changes
Text
src/main/resources/db/migration/
├── V1__create_users_table.sql
├── V2__add_email_index.sql
├── V3__create_orders_table.sql
├── V4__add_order_status_column.sql
└── R__populate_status_lookup.sql

Versioned Migrations

Execute the SQL statements below against your target database. Review each statement before running in production.

SQL
-- V1__create_users_table.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    username   VARCHAR(50)  NOT NULL UNIQUE,
    email      VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- V2__add_email_index.sql
CREATE INDEX idx_users_email ON users (email);

-- V3__create_orders_table.sql
CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT       NOT NULL REFERENCES users(id),
    status      VARCHAR(20)  NOT NULL DEFAULT 'PENDING',
    total       NUMERIC(12,2),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- V4__add_order_status_column.sql
ALTER TABLE orders ADD COLUMN notes TEXT;
ALTER TABLE orders ADD CONSTRAINT chk_status
    CHECK (status IN ('PENDING','CONFIRMED','SHIPPED','DELIVERED','CANCELLED'));

Flyway records each applied migration in flyway_schema_history with the version, description, checksum, and execution time. If a previously applied script's checksum changes, Flyway throws an error to prevent silent schema drift.

Repeatable Migrations

Repeatable migrations (prefix R__) are re-applied whenever their checksum changes. Use them for views, stored procedures, and reference data that you want to keep in sync with the code.

SQL
-- R__create_order_summary_view.sql
-- Flyway re-runs this whenever the file content changes
CREATE OR REPLACE VIEW order_summary AS
SELECT
    u.username,
    COUNT(o.id)      AS total_orders,
    SUM(o.total)     AS lifetime_value,
    MAX(o.created_at) AS last_order_at
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.username;
SQL
-- R__seed_status_lookup.sql
-- Safe upsert so re-running doesn't duplicate rows
INSERT INTO status_lookup (code, label)
VALUES
  ('PENDING',   'Pending'),
  ('CONFIRMED', 'Confirmed'),
  ('SHIPPED',   'Shipped'),
  ('DELIVERED', 'Delivered'),
  ('CANCELLED', 'Cancelled')
ON CONFLICT (code) DO UPDATE SET label = EXCLUDED.label;

Java-Based Migrations

When SQL is not enough (for example, to encrypt existing data or perform complex transformations) implement JavaMigration. Spring beans can be injected via the constructor.

Java
// V5__encrypt_user_emails.java
// Place in the same package as configured in spring.flyway.locations (classpath:db/migration)
@Component
public class V5__encrypt_user_emails implements JavaMigration {

    // Spring Boot 3.x: inject beans by making the migration a Spring component
    private final EncryptionService encryptionService;

    public V5__encrypt_user_emails(EncryptionService encryptionService) {
        this.encryptionService = encryptionService;
    }

    @Override
    public MigrationVersion getVersion() {
        return MigrationVersion.fromVersion("5");
    }

    @Override
    public String getDescription() {
        return "Encrypt existing user emails";
    }

    @Override
    public Integer getChecksum() {
        return 1;   // bump to re-run
    }

    @Override
    public boolean isBaselineMigration() { return false; }

    @Override
    public boolean canExecuteInTransaction() { return true; }

    @Override
    public void migrate(Context context) throws Exception {
        try (var stmt = context.getConnection().createStatement();
             var rs = stmt.executeQuery("SELECT id, email FROM users")) {
            while (rs.next()) {
                long id = rs.getLong("id");
                String encrypted = encryptionService.encrypt(rs.getString("email"));
                try (var upd = context.getConnection().prepareStatement(
                        "UPDATE users SET email = ? WHERE id = ?")) {
                    upd.setString(1, encrypted);
                    upd.setLong(2, id);
                    upd.executeUpdate();
                }
            }
        }
    }
}
YAML
# Register Java migrations location
spring:
  flyway:
    locations:
      - classpath:db/migration      # SQL scripts
      - classpath:db/javamigration  # Java migrations

Configuration Reference

The settings below can be placed in application.yml or application.properties. All keys shown are optional unless marked: Spring Boot applies sensible defaults when they are omitted.

YAML
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    schemas: public              # schemas to manage
    table: flyway_schema_history # history table name
    baseline-on-migrate: false   # true = treat existing DB as already at baseline-version
    baseline-version: 0          # version used for baseline
    baseline-description: "Initial baseline"
    out-of-order: false          # allow migrations with lower version than latest applied
    validate-on-migrate: true    # fail if checksum mismatch detected
    clean-disabled: true         # IMPORTANT: prevent accidental DROP in production
    placeholders:                # ${placeholder} substitution in SQL scripts
      schema: public
      tablePrefix: app_
    target: latest               # stop at a specific version (e.g., "3") for testing
Always set spring.flyway.clean-disabled=true in production. The flyway:clean command drops everything in the managed schemas: destructive and irreversible.

Callbacks

Callbacks let you hook into Flyway lifecycle events, before migration, after each migration, on validation error, and more.

Java
@Component
public class FlywayAuditCallback implements Callback {

    private static final Logger log = LoggerFactory.getLogger(FlywayAuditCallback.class);

    @Override
    public boolean supports(Event event, Context context) {
        return event == Event.AFTER_EACH_MIGRATE || event == Event.AFTER_MIGRATE_ERROR;
    }

    @Override
    public boolean canHandleInTransaction(Event event, Context context) {
        return true;
    }

    @Override
    public void handle(Event event, Context context) {
        MigrationInfo info = context.getMigrationInfo();
        if (event == Event.AFTER_EACH_MIGRATE) {
            log.info("Migration applied: {} — {} in {}ms",
                info.getVersion(), info.getDescription(), info.getExecutionTime());
        } else {
            log.error("Migration FAILED: {} — {}",
                info.getVersion(), info.getDescription());
        }
    }

    @Override
    public String getCallbackName() { return "FlywayAuditCallback"; }
}

Baseline & Repair

Baseline: use when Flyway is introduced into an existing database that already has a schema. Baseline stamps the current state as version N without running any scripts up to that version.

YAML
# First deployment on an existing database
spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1          # marks existing DB as "at V1"
    baseline-description: "Existing schema before Flyway"
Shell
# Or via CLI
flyway -url=jdbc:postgresql://... -user=... baseline -baselineVersion=1

Repair: fixes failed migrations (removes them from the history table) and recalculates checksums after a script is edited. Run repair before retrying a failed migration.

Java
// Programmatic repair in a Spring Boot @Bean or @PostConstruct
@Bean
public FlywayMigrationStrategy flywayRepairStrategy() {
    return flyway -> {
        flyway.repair();    // fix history table
        flyway.migrate();   // then apply pending
    };
}

Testing with Testcontainers

Testcontainers spins up a real database (or broker) in Docker for each test run, giving you integration-test confidence without a shared external service. The @DynamicPropertySource method wires the container's JDBC URL into Spring's environment before the application context starts.

XML
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <scope>test</scope>
</dependency>
Java
@SpringBootTest
@Testcontainers
class FlywayMigrationTest {

    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void allMigrationsApplyCleanly() {
        // If Flyway fails, the context won't start and the test fails automatically.
        // Verify the expected schema exists:
        Integer count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'users'",
            Integer.class);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void schemaHistoryContainsAllVersions() {
        List<String> versions = jdbcTemplate.queryForList(
            "SELECT version FROM flyway_schema_history WHERE success = true ORDER BY installed_rank",
            String.class);
        assertThat(versions).containsExactly("1", "2", "3", "4");
    }
}