Spring Boot Database Migration using Flyway
Your application’s code changes all the time, and so does its database: new tables, new columns, new indexes. Keeping every database, on your laptop, on the test server and in production, in step with the code is surprisingly hard to do by hand. Database migrations solve this by turning every schema change into a versioned script that runs automatically, in the right order, exactly once.
This tutorial shows how to manage migrations in Spring Boot with Flyway, a widely used open-source migration tool: setup, writing and running migrations, evolving a schema safely, configuration, existing databases, testing and best practices.
1. What Is Database Migration?
A database migration is a script that changes the database structure, or its data, from one version to the next. Together, the migrations form a history of your schema, just as Git commits form a history of your code:
| Version | Change |
|---|---|
| 1 | Create the books table |
| 2 | Add an isbn column with a unique index |
| 3 | Create an authors table and link books to authors |
A migration tool records which versions each database already has and applies only the missing ones. A brand-new database runs all three; a database already at version 2 runs only version 3. Every environment ends up with exactly the same structure.
2. Why Not Just Use ddl-auto?
Hibernate can create tables automatically from your entities with spring.jpa.hibernate.ddl-auto=update. That is convenient while prototyping, but risky for real applications:
ddl-auto=update | Flyway migrations | |
|---|---|---|
| Who writes the SQL | Hibernate guesses it from entities | You, explicitly |
| Renaming a column | Adds a new column; the old one and its data stay behind | One clear ALTER TABLE ... RENAME |
| Data changes | Not possible | Any SQL, including moving data |
| Review before production | Hard: changes happen at startup | Easy: scripts are in version control |
| Repeatable across environments | Not guaranteed | Yes, same scripts everywhere |
| History | None | Every change recorded |
A safe combination: let Flyway own the schema, and set Hibernate to only check it with ddl-auto=validate.
3. What Is Flyway?
Flyway is a database migration tool built around plain SQL files. Its free Community edition is open source under the Apache 2.0 license, and Spring Boot supports it out of the box. It works like this:
- You place numbered SQL files in
src/main/resources/db/migration. - At startup, Flyway looks for a table called
flyway_schema_historyin the database, and creates it if it is missing. - It compares the files with the history table and runs every file that has not been applied yet, in version order.
- After each file, it records the version, description, checksum and result in the history table.
The checksum is a fingerprint of each file’s content. If someone edits a file that was already applied, Flyway notices the changed checksum and refuses to continue, so databases can never silently drift apart.
4. Adding Flyway to Spring Boot
Add Flyway and, for most databases, its database-specific module. Spring Boot manages the versions:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Database support module, e.g. for PostgreSQL -->
<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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Recent Flyway versions keep support for each database in a separate module, such as flyway-database-postgresql or flyway-mysql. If startup reports an “unsupported database” error, this module is usually missing.
spring.datasource.url=jdbc:postgresql://localhost:5432/library
spring.datasource.username=library
spring.datasource.password=${DB_PASSWORD}
# Flyway owns the schema; Hibernate only checks that entities match it
spring.jpa.hibernate.ddl-auto=validate
That is all the configuration needed. Spring Boot detects Flyway on the classpath and runs migrations automatically at startup, before JPA starts, so your entities always meet an up-to-date schema.
5. Writing Your First Migration
Create the folder src/main/resources/db/migration and add the first script:
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
author_name VARCHAR(150),
published_on DATE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
The file name follows a strict pattern that Flyway relies on:
V1__create_books_table.sql
V -> versioned migration
1 -> version number
__ -> two underscores separate the version from the description
create_books_table -> description (underscores become spaces in the history)
.sql -> file type
The matching entity, which Hibernate validates against this table:
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
private LocalDate publishedOn;
@Column(nullable = false, updatable = false, insertable = false)
private Instant createdAt;
protected Book() {
}
public Book(String title, LocalDate publishedOn) {
this.title = title;
this.publishedOn = publishedOn;
}
// getters
}
6. Running Migrations
Start the application. Flyway logs what it does:
Flyway Community Edition by Redgate
Database: jdbc:postgresql://localhost:5432/library (PostgreSQL 16)
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "1 - create books table"
Successfully applied 1 migration to schema "public", now at version v1
Look inside the history table to see the record:
SELECT version, description, success FROM flyway_schema_history;
| version | description | success |
|---|---|---|
| 1 | create books table | true |
Restart the application, and Flyway reports “Schema ‘public’ is up to date. No migration necessary.” Each migration runs exactly once per database.
7. Evolving the Schema
Never change a migration that has already run somewhere. Each new change is a new file with the next version number.
7.1 Adding a column and an index
ALTER TABLE books ADD COLUMN isbn VARCHAR(13);
CREATE UNIQUE INDEX ux_books_isbn ON books (isbn);
@Column(length = 13, unique = true)
private String isbn;
The entity change and the migration go into the same commit, so code and schema always match.
7.2 A new table, with data moved into it
Migrations can change data, not just structure. Here, author names that were stored as plain text move into their own table:
CREATE TABLE authors (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL UNIQUE
);
ALTER TABLE books ADD COLUMN author_id BIGINT REFERENCES authors (id);
-- Move existing author names into the new table
INSERT INTO authors (name)
SELECT DISTINCT author_name FROM books WHERE author_name IS NOT NULL;
UPDATE books b
SET author_id = a.id
FROM authors a
WHERE a.name = b.author_name;
ALTER TABLE books DROP COLUMN author_name;
Hibernate could never do this on its own: it has no way of knowing that data must be copied before a column is dropped. With Flyway, the whole change is one reviewed script, applied identically everywhere. The author_name column was created in V1, and V3 replaces it with a proper relationship.
8. Naming Rules and Migration Types
| Prefix | Type | Runs | Example |
|---|---|---|---|
V | Versioned | Once, in version order | V4__add_genre_column.sql |
R | Repeatable | After all versioned migrations, whenever its content changes | R__books_overview_view.sql |
U | Undo | Only on an explicit undo command (paid editions) | U4__add_genre_column.sql |
Version numbers can contain dots or underscores for finer steps, such as V1_1__fix_index.sql, which is version 1.1. Two common styles:
| Style | Example | Good for |
|---|---|---|
| Simple integers | V7__... | Small teams with few parallel branches |
| Timestamps | V2026_10_04_0930__... | Many developers creating migrations at the same time |
Whichever you choose, use it consistently. The description is up to you, but make it say what changes, such as add_isbn_to_books, not update or fix.
9. Repeatable Migrations
Some database objects are easier to replace than to change step by step, such as views, functions and stored procedures. Repeatable migrations, prefixed with R__, re-run whenever their content changes:
CREATE OR REPLACE VIEW books_overview AS
SELECT b.id,
b.title,
b.isbn,
a.name AS author
FROM books b
LEFT JOIN authors a ON a.id = b.author_id;
To change the view, simply edit this file. Flyway sees the new checksum and runs it again after any pending versioned migrations. Because it may run many times, a repeatable migration must always be safe to repeat, which is why it uses CREATE OR REPLACE.
10. Java-Based Migrations
When SQL alone is awkward, for example when data must be transformed with Java logic, write a migration as a Java class. Put it in a db.migration package and follow the same naming rule:
package db.migration;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
public class V5__Normalize_isbn_numbers extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
try (Statement select = context.getConnection().createStatement();
ResultSet rows = select.executeQuery("SELECT id, isbn FROM books WHERE isbn IS NOT NULL");
PreparedStatement update = context.getConnection()
.prepareStatement("UPDATE books SET isbn = ? WHERE id = ?")) {
while (rows.next()) {
String digitsOnly = rows.getString("isbn").replaceAll("[^0-9]", "");
update.setString(1, digitsOnly);
update.setLong(2, rows.getLong("id"));
update.addBatch();
}
update.executeBatch();
}
}
}
Java migrations are recorded in the same history table and run in the same version order as SQL files. Use them only when SQL is not enough; plain SQL is easier to read and review.
11. Configuration Options
| Property | Purpose | Default |
|---|---|---|
spring.flyway.enabled | Run migrations at startup | true |
spring.flyway.locations | Where to find migrations | classpath:db/migration |
spring.flyway.baseline-on-migrate | Start tracking an existing database (section 12) | false |
spring.flyway.baseline-version | Version given to the existing schema | 1 |
spring.flyway.validate-on-migrate | Check checksums before migrating | true |
spring.flyway.out-of-order | Allow older versions to run after newer ones | false |
spring.flyway.clean-disabled | Block the command that deletes everything | true |
spring.flyway.schemas | Schemas Flyway manages | The connection’s default |
spring.flyway.placeholders.* | Values substituted into scripts as ${name} | None |
Flyway uses the application’s DataSource by default. To run migrations with a different, more powerful database user, set spring.flyway.user and spring.flyway.password, so the application itself can run with fewer privileges.
12. Adding Flyway to an Existing Database
If your production database already exists, Flyway must not try to create tables that are already there. A baseline tells Flyway: “everything up to this version is already in place”:
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
- Export the current schema as
V1__baseline.sql, so new environments can be built from scratch. - Enable
baseline-on-migrate. On the existing database, Flyway records version 1 as applied without running it. - Write all new changes as
V2__...,V3__...and so on.
From then on, existing and new databases follow exactly the same path. Once every environment has been baselined, you can switch baseline-on-migrate off again.
13. Migrations Across Environments
Use placeholders for values that differ between environments, such as a read-only reporting user:
GRANT SELECT ON books, authors TO ${reportingUser};
spring.flyway.placeholders.reportingUser=reporting_prod
For data that should exist only in some environments, such as sample books for local development, add an extra location in that profile:
spring.flyway.locations=classpath:db/migration,classpath:db/dev-data
INSERT INTO books (title, isbn, published_on)
VALUES ('Clean Architecture Basics', '9780000000001', '2024-03-01'),
('Learning Spring Boot', '9780000000002', '2025-06-15');
The high version number keeps sample data after all real migrations. Production never sees these files, because its profile does not include the dev-data location. Read more about profiles in Spring Boot Profiles.
14. Checking Migration Status
With Spring Boot Actuator, the flyway endpoint lists every migration and its state:
management.endpoints.web.exposure.include=health,flyway
{
"contexts": {
"library-service": {
"flywayBeans": {
"flyway": {
"migrations": [
{ "version": "1", "description": "create books table", "state": "SUCCESS" },
{ "version": "2", "description": "add isbn to books", "state": "SUCCESS" },
{ "version": "3", "description": "create authors and link books", "state": "PENDING" }
]
}
}
}
}
}
PENDING means a migration exists in the code but has not been applied to this database yet. Keep this endpoint secured, as it reveals details about your schema.
15. Testing Migrations
Migrations are code, so test them against the same database type you use in production. An in-memory H2 database speaks a different SQL dialect and may accept scripts that PostgreSQL rejects, or the other way round. Testcontainers starts a real database in Docker for the test:
@SpringBootTest
@Testcontainers
class MigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
private Flyway flyway;
@Autowired
private BookRepository bookRepository;
@Test
void allMigrationsApplySuccessfully() {
MigrationInfo[] applied = flyway.info().applied();
assertThat(applied).isNotEmpty();
assertThat(applied).allMatch(info -> info.getState().isApplied());
}
@Test
void entitiesWorkWithTheMigratedSchema() {
Book saved = bookRepository.save(new Book("Testing with Flyway", LocalDate.of(2026, 1, 15)));
assertThat(saved.getId()).isNotNull();
}
}
The application starts, Flyway runs every migration on a fresh PostgreSQL, and Hibernate’s validate setting confirms the entities match. If a migration has a syntax error, the build fails long before production. @ServiceConnection connects Spring Boot to the container automatically (Spring Boot 3.1+).
16. Flyway vs Liquibase
| Flyway | Liquibase | |
|---|---|---|
| Migration format | Plain SQL (or Java) | SQL, XML, YAML or JSON “changesets” |
| Learning curve | Very low: just SQL files | Higher: its own change format |
| Database independence | You write SQL for your database | Abstract changes can generate SQL for many databases |
| Rollback | Write a new forward migration (undo files in paid editions) | Rollback blocks can be defined per change |
| Spring Boot support | Built in | Built in |
| Best for | Teams comfortable with SQL, one main database | Products supporting several database vendors |
Both are solid choices. For most Spring Boot applications on a single database, Flyway’s plain SQL approach is the simplest to adopt and review.
17. Best Practices
- Never edit a migration after it has run on a shared database; add a new one instead.
- Commit migrations with the code that needs them, so they are reviewed together.
- Keep each migration small and focused on one change.
- Set
ddl-auto=validateso mismatches between entities and schema fail fast. - Test migrations on the real database type, for example with Testcontainers.
- Keep
cleandisabled everywhere except throwaway local databases. - Plan for large tables: adding indexes or rewriting millions of rows can lock tables and slow startup.
- Make changes backward compatible when old and new application versions run side by side during a deployment.
18. Common Mistakes to Avoid
| Mistake | What goes wrong | Fix |
|---|---|---|
| Editing an applied migration | Startup fails with a checksum mismatch | Revert the edit and add a new migration |
One underscore instead of two (V1_create.sql) | Flyway ignores or misreads the file | Use V1__create.sql |
Keeping ddl-auto=update with Flyway | Hibernate and Flyway both change the schema | Use validate or none |
| Testing only on H2 | Scripts fail on the production database | Test with the real database in a container |
Two branches both adding V7 | Version conflict after merging | Timestamp versions, or renumber before merging |
Missing database module (flyway-database-*) | “Unsupported database” at startup | Add the module for your database |
19. Summary
Database migrations make schema changes versioned, reviewable and repeatable. With Flyway in Spring Boot, add the dependency, write numbered SQL files in db/migration, and every environment is brought up to date automatically at startup, before JPA starts. Add new files instead of editing old ones, use repeatable migrations for views, Java migrations for complex data changes, baselines for existing databases and placeholders or extra locations for environment differences. Validate the schema with Hibernate, test migrations on the real database type, and your database will evolve as safely as your code.
20. Frequently Asked Questions (FAQ)
Q1. I edited an applied migration and now startup fails with a checksum mismatch. How do I fix it?
Flyway detected that the file no longer matches what was applied. The right fix depends on where the old version ran:
- It ran on shared or production databases: undo your edit, restoring the original file, and put the change in a new migration.
- It ran only on your own local database: either recreate your local database, or tell Flyway to accept the new checksum with the
repaircommand.
# With the Flyway Maven plugin configured for your local database
./mvnw flyway:repair
Never use repair on production just to silence the error. The error is protecting you from databases that differ without anyone knowing.
Q2. Can I roll back a migration?
In the free edition, Flyway does not undo migrations automatically. The common approach is to roll forward: write a new migration that reverses the change.
-- V7__add_discount_column.sql
ALTER TABLE books ADD COLUMN discount NUMERIC(5,2);
-- V8__remove_discount_column.sql (the "rollback")
ALTER TABLE books DROP COLUMN discount;
Rolling forward keeps the history honest: every database goes through the same steps. Paid editions offer undo files (U prefix), but even then, many teams prefer forward fixes plus regular database backups.
Q3. Two developers created a migration with the same version number. What now?
Flyway refuses to start when two files share a version. Before merging, the second developer renames their file to the next free number. To make collisions rare in busy teams:
- Use timestamp versions, such as
V2026_10_04_1015__add_genre.sql, which are almost never identical. - Check for pending migrations from
mainbefore merging your branch.
If an older version arrives after newer ones have already run, Flyway skips it by default. Setting spring.flyway.out-of-order=true lets it run, but use that carefully, because the order then differs between databases.
Q4. Does Flyway slow down every application startup?
Only slightly when nothing is pending. Flyway reads the history table, compares checksums and finishes in a fraction of a second.
Startup is slow only while new migrations run, and only as slow as their SQL. A migration that adds an index to a table with millions of rows may take minutes. Run such heavy migrations during a quiet period, or consider running Flyway as a separate step before deploying the application (see the next question).
Q5. Can I run migrations without starting the whole application?
Yes. Many teams run migrations as a separate deployment step, so a failure stops the release before any new code starts:
- The Flyway Maven or Gradle plugin:
./mvnw flyway:migrate. - The Flyway command-line tool, or its Docker image, in your deployment pipeline.
- A Kubernetes job or init container that runs before the application pods.
If you migrate this way, set spring.flyway.enabled=false in the application, or leave it on as a harmless check, since nothing will be pending.
Q6. What happens if several instances of my application start at the same time?
Only one of them migrates. Flyway takes a lock in the database before applying migrations; the other instances wait until it is released, then see that the schema is already up to date and continue.
This makes it safe to start several instances, for example several Kubernetes pods. Long migrations will make the waiting instances start slowly, so keep migrations short, or run them as a separate step.
Q7. How do I write different SQL for different databases?
Use the {vendor} placeholder in the location. Spring Boot replaces it with the detected database type:
spring.flyway.locations=classpath:db/migration/common,classpath:db/migration/{vendor}
db/migration/common/V1__create_books_table.sql <- works everywhere
db/migration/postgresql/V2__add_search_index.sql <- PostgreSQL only
db/migration/h2/V2__add_search_index.sql <- H2 only
Keep this to a minimum: two versions of the same change can drift apart. Testing against the real production database type, as shown in section 15, usually removes the need for it.
Q8. How should I insert reference data, such as countries or categories?
Reference data that every environment needs belongs in a normal versioned migration:
-- V9__insert_book_categories.sql
INSERT INTO categories (code, name) VALUES
('FIC', 'Fiction'),
('SCI', 'Science'),
('HIS', 'History');
Data that only makes sense in development, such as sample books, belongs in a separate location used only by the dev profile, as shown in section 13. Never put test users or fake orders in migrations that run in production.
Q9. How do I rename a column without downtime?
During a rolling deployment, old and new application versions run at the same time. A direct rename breaks the old version immediately. Use the expand and contract approach, spread over several releases:
- Expand: add the new column and copy the data. Both columns exist.
- Migrate code: release code that writes to both columns and reads the new one.
- Contract: once no old version is running, drop the old column in a later migration.
-- Release 1: V10__add_published_date.sql
ALTER TABLE books ADD COLUMN published_date DATE;
UPDATE books SET published_date = published_on;
-- Release 3: V12__drop_published_on.sql
ALTER TABLE books DROP COLUMN published_on;
Q10. What is Flyway’s clean command, and why is it disabled?
clean drops every object in the managed schemas: tables, views, data, everything. It is handy for resetting a local development database, and catastrophic anywhere else.
That is why recent versions disable it by default (spring.flyway.clean-disabled=true). Keep it that way in every shared configuration. If you need a fresh local database, it is often simpler to recreate the database container than to enable clean.
Q11. Should migrations live in the same repository as the application?
Yes, in most cases. When the migration and the code that needs it are in the same commit:
- Reviewers see the schema change next to the code that uses it.
- Every build of the application contains exactly the migrations it expects.
- Tests run the migrations automatically.
A separate repository makes sense mainly when a database is shared by several applications, which is itself something to avoid where possible: in microservices, each service should own its own database.
Q12. Can Flyway manage several database schemas?
Yes. List them, and Flyway creates any that are missing and records its history in the first one:
spring.flyway.schemas=library,reporting
-- V11__create_reporting_tables.sql
CREATE TABLE reporting.monthly_loans (
month DATE PRIMARY KEY,
loans_count INTEGER NOT NULL
);
Inside migrations, use schema-qualified names, such as reporting.monthly_loans, for objects outside the default schema.
Q13. How can I see exactly which SQL Flyway executed?
Three places to look:
- The scripts themselves in
db/migration, which are the exact SQL that ran. - The history table, which records each migration, who ran it, when, how long it took and whether it succeeded.
- Debug logging for detailed output during a run:
logging.level.org.flywaydb=DEBUG
SELECT installed_rank, version, description, installed_by, installed_on, execution_time, success
FROM flyway_schema_history
ORDER BY installed_rank;
Q14. What does ddl-auto=validate check?
At startup, after Flyway has migrated, Hibernate compares your entities with the real database and stops the application if they do not match. It checks that:
- Every entity’s table exists.
- Every mapped field has a column.
- Column types are compatible with the field types.
It does not change anything, and it ignores extra columns that no entity maps. This catches the classic mistake of adding a field to an entity but forgetting the migration, at startup instead of at the first failing query.
Q15. Can I write migrations in XML or YAML instead of SQL?
Not with Flyway: it uses plain SQL files and, when needed, Java classes. That is a deliberate design choice, because SQL is what your database actually runs, so there is nothing to translate or learn.
If you want database-independent change definitions in XML, YAML or JSON, Liquibase is built around that approach and is equally well supported by Spring Boot. The comparison table in section 16 helps you choose.
Q16. How do I add a NOT NULL column to a table that already has data?
Adding a NOT NULL column without a value fails, because existing rows would break the rule. Do it in steps:
-- 1. Add the column as nullable
ALTER TABLE books ADD COLUMN language VARCHAR(10);
-- 2. Fill existing rows
UPDATE books SET language = 'en' WHERE language IS NULL;
-- 3. Now enforce the rule
ALTER TABLE books ALTER COLUMN language SET NOT NULL;
For a simple constant, many databases also accept ADD COLUMN language VARCHAR(10) NOT NULL DEFAULT 'en' in one statement. On very large tables, updating every row can take a long time, so test the migration on a copy of production-sized data first.
Q17. What happens if a migration fails halfway?
It depends on your database:
| Database | Schema changes inside a transaction? | After a failure |
|---|---|---|
| PostgreSQL, SQL Server | Yes | The whole migration is rolled back; fix the script and start again |
| MySQL, MariaDB, Oracle | No: each schema statement commits immediately | Earlier statements may remain; clean up by hand |
Either way, Flyway records the failure and refuses to continue until the problem is resolved. On databases without transactional schema changes, keep each migration to one schema change, so a failure never leaves a half-finished state, and test migrations before they reach production.
Q18. Is Flyway free?
The Community edition is free and open source under the Apache 2.0 license, and includes everything covered in this tutorial: versioned and repeatable migrations, Java migrations, baselines, placeholders and Spring Boot integration.
Paid editions, now owned by Redgate, add features such as undo migrations, dry runs that show SQL without executing it, and extra database support. Most Spring Boot projects never need them.
Q19. My project has hundreds of old migrations. Can I squash them?
Yes, but only carefully. A common approach once every environment is up to date:
- Export the current schema as a single script, for example
V200__baseline.sql. - Move the old migrations out of the active location, keeping them in version control history.
- On existing databases, set
baseline-version=200withbaseline-on-migrate, so they skip the new baseline. - New databases run the single baseline instead of hundreds of files.
Do it only when the number of files really causes slow builds or tests. Old migrations are otherwise harmless, and keeping them is simpler.
Q20. Is Flyway useful in microservices?
Very much. In microservices, each service should own its own database, and Flyway fits that perfectly: each service carries its own migrations and updates its own schema when it is deployed, independently of every other service.
This keeps services decoupled: no shared schema, no coordination of database changes across teams. See the Database per Service Pattern for the reasoning behind this design.