[MM’s] Boot Notes — Flyway Patterns for Real-World Teams

Database Migrations Done Right in Spring Boot

Database Migrations Done Right in Spring Boot

If you’re building Spring Boot applications in 2025, database migrations are no longer a “junior dev concern.” They’re foundational infrastructure code and if you’re using Spring Boot, Flyway becomes an even more important part of making your database evolution boring, predictable and safe.

⚡ TL;DR (Quick Recap)

  • Flyway + Spring Boot gives you zero-config migration setup with profile-based deployment strategies.
  • Baseline migrations solve the “200 SQL files, 10-minute startup” problem.
  • Separate app deployment from database migration in production.
  • Test everything with Testcontainers.

Why This Topic Matters Right Now

Spring Boot introduced the most polished Flyway integration yet. Combined with Java 21+, record-heavy domain models and Testcontainers-native testing, migrations are finally becoming something you can build discipline around.

But the real reason this matters?

Your application code changes weekly. Your database schema survives for years.

The cost of getting a migration wrong isn’t a failed build — it’s corrupted production data, rollback nightmares…

Let’s make migrations boring, predictable and a first-class citizen in your Spring Boot workflow.

Flyway + Spring Boot: A Smooth Start

Spring Boot gives you plug-and-play Flyway support out of the box. Add two dependencies:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>

Configure it:

spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: dbuser
password: dbpass
flyway:
locations: classpath:db/migration
validate-on-migrate: true
clean-disabled: true
schemas: flyway,dbo
default-schema: flyway

And drop your migrations into:

src/main/resources/db/migration

Names matter:

V1__init.sql
V2.1__add_users_table.sql
V3__create_orders.sql

Start your app → Flyway runs → done. This is already great, but it’s just the beginning.

The Real-World Problems Every Team Hits

Problem 1: The ORM Trap

You start with:

spring.jpa.hibernate.ddl-auto=update

It works on your laptop. Then someone renames a JPA field. Hibernate doesn’t rename the column — it silently creates a new one. Now your database has zombie columns.

Problem 2: Someone Modified a Past Migration

Flyway checksum mismatch. Your pipeline refuses to deploy. Debugging begins: “Did we fix the file or create a new migration?” (This should never be a question.)

Problem 3: Startup Time Crawl

Your CI runs 217 migrations every time Testcontainers boots a database. The pipeline slows. Developers skip writing integration tests because they take too long.

Problem 4: Race Conditions in Distributed Systems

When your Kubernetes deployment scales up, 10 pods may try to run migrations at once. Flyway locks help — but running migrations at startup is still not ideal.

Profile-Based Migration Strategy: The Most Underrated Pattern

In development, you want Flyway to auto-apply migrations. In production, you absolutely do NOT want Flyway to run changes automatically during app startup.
When a Kubernetes pod restarts during a deployment, it shouldn’t block startup waiting for a 5-minute schema migration. Worse, if
multiple pods start simultaneously, they’ll race for the migration
lock, causing cascading delays.

Here’s the pattern every team should use:

@Configuration
public class FlywayConfig {
@Bean
@Profile("!prod")
public FlywayMigrationStrategy migrateStrategy() {
return Flyway::migrate;
}
@Bean
@Profile("prod")
public FlywayMigrationStrategy validateStrategy() {
return Flyway::validate;
}
}

What this gives you:

  • Dev → Migrations run automatically, nice feedback loop.
  • Prod → Flyway validates but does not migrate.

Production workflow:

# Step 1 — Run schema migration as a separate deployment step
./mvnw flyway:migrate -Dflyway.configFiles=flyway-prod.conf

This decouples app deploy from schema changes — a must for blue-green and zero-downtime rollouts.

Baseline Migrations: When You Have Too Many SQL Files

After months or years of development, you accumulate migrations:

V1__init.sql
V2__users.sql
V3__orders.sql
...
V214__optimizations.sql

The problem: After accumulating 214 migrations over 2 years, every
fresh database (CI runs, new developer onboarding, Testcontainers
tests) replays all 214 scripts sequentially.

The solution: Create a baseline migration that’s a snapshot of your
current schema. For existing databases, Flyway marks it as “already
applied.” For fresh databases, it runs this single comprehensive
script instead of 214 incremental ones.

The fix: a baseline migration.

V1__init.sql
...
V214__whatever.sql
B214__baseline.sql ← a single script representing current schema
V215__add_tags.sql

Result: Fresh startup time drops from ~10 minutes to ~45 seconds.

Multi-Schema Migration: Separating Your Metadata

A good production practice is keeping Flyway’s own tables out of your application schema:

spring:
flyway:
schemas: flyway,dbo
default-schema: flyway
create-schemas: true

What this means:

  • flyway_schema_history lives in flyway
  • Your applications tables live in dbo

Your application user only needs permissions for dbo. Your migration runner user needs elevated rights for both. Security + clean boundaries.

Writing Safe, Backward-Compatible Migrations

This is where large teams often fall over.

If you’re deploying with rolling updates, your new app version must work with the old schema — and your old app version must work with the new schema.

Follow this four-phase approach:

Phase 1 — Additive, safe changes

Add new columns, nullable or with defaults:

ALTER TABLE products ADD COLUMN status VARCHAR(20) DEFAULT 'ACTIVE';

Phase 2 — Dual writes

Write to both the old and new column (via app logic or triggers).

Phase 3 — Switch reads

App starts reading from the new column.

Phase 4 — Cleanup

Drop old columns after verifying no consumers rely on them.

Golden rules:

  • Never rename columns, add new ones.
  • Never drop columns until multiple releases have passed.
  • Avoid destructive changes during peak traffic windows.
  • Use views to smooth transitions when needed.

Transactional DDL Isn’t Always Available

PostgreSQL supports transactional DDL → good. MySQL, MariaDB and some Oracles → not so much.

CREATE TABLE employees (...);
INSERT INTO employees VALUES (...); -- fails

Postgres → everything rolls back. MySQL → table is created, insert fails, inconsistent state. Always test migrations in the same database engine as prod.

Testing Migrations with Testcontainers

Treat migrations as code. Test them.

Example configuration:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("product")
.withUsername("user")
.withPassword("pass123");
}
}

Verify migration success:

@SpringBootTest
@Import(TestcontainersConfig.class)
class FlywayMigrationTest {
@Autowired
Flyway flyway;
@Test
void allMigrationsShouldApply() {
var info = flyway.info();
assertThat(info.all())
.allMatch(m -> m.getState().isApplied());
}
}

And test schema behavior with repositories. Testing migrations avoids production surprises — the only kind of surprises nobody wants.

Comparing Flyway

Flyway

  • Simple versioning
  • Great Spring Boot integration
  • Easy to reason about
  • Large ecosystem

Best for 90% of JVM applications.

Liquibase

  • Rollback support
  • XML/JSON/YAML changelogs
  • Conditional logic

Best for complex enterprise environments.

Hibernate DDL Auto

Good for prototypes; never for production.

schema.sql + data.sql

Fine for demos, not for real apps. Will be removed eventually.

Final Takeaways

Flyway isn’t just a migration tool — it’s a foundation for safe, repeatable, controlled database evolution. When you combine Spring Boot’s improved integration, baseline strategies, profile-based deployment and Testcontainers for validation, migrations stop being operational headaches.

You can find all the code on GitHub.

Originally posted on marconak-matej.medium.com.