00:00

Database Migration using Flyway in Spring Boot

Last updated:

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?

Database migration is the process of making changes to a database's structure or moving data from one database to another in a controlled and organized manner.

It is commonly used to create, modify, or delete database tables, columns, indexes, and constraints while keeping the database structure consistent with the application's requirements.

Database migration helps developers manage database changes across different environments, such as development, testing, and production.

Database Migration Tools

There are the common database migration tools include:

  • Flyway — Commonly used with Spring Boot applications.

  • Liquibase — Supports version-controlled database changes.

  • Alembic — Commonly used with SQLAlchemy and Python.

  • EF Core Migrations — Used with Entity Framework Core in .NET applications.

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=updateFlyway migrations
Who writes the SQLHibernate guesses it from entitiesYou, explicitly
Renaming a columnAdds a new column; the old one and its data stay behindOne clear ALTER TABLE ... RENAME
Data changesNot possibleAny SQL, including moving data
Review before productionHard: changes happen at startupEasy: scripts are in version control
Repeatable across environmentsNot guaranteedYes, same scripts everywhere
HistoryNoneEvery 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:

  1. You place numbered SQL files in src/main/resources/db/migration.
  2. At startup, Flyway looks for a table called flyway_schema_history in the database, and creates it if it is missing.
  3. It compares the files with the history table and runs every file that has not been applied yet, in version order.
  4. 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:

pom.xml
<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.

application.properties
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:

src/main/resources/db/migration/V1__create_books_table.sql
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:

File name anatomy
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:

Book.java
@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:

Console
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:

Terminal (psql)
SELECT version, description, success FROM flyway_schema_history;
versiondescriptionsuccess
1create books tabletrue

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

V2__add_isbn_to_books.sql
ALTER TABLE books ADD COLUMN isbn VARCHAR(13);

CREATE UNIQUE INDEX ux_books_isbn ON books (isbn);
Book.java
@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:

V3__create_authors_and_link_books.sql
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

PrefixTypeRunsExample
VVersionedOnce, in version orderV4__add_genre_column.sql
RRepeatableAfter all versioned migrations, whenever its content changesR__books_overview_view.sql
UUndoOnly 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:

StyleExampleGood for
Simple integersV7__...Small teams with few parallel branches
TimestampsV2026_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:

R__books_overview_view.sql
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:

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

PropertyPurposeDefault
spring.flyway.enabledRun migrations at startuptrue
spring.flyway.locationsWhere to find migrationsclasspath:db/migration
spring.flyway.baseline-on-migrateStart tracking an existing database (section 12)false
spring.flyway.baseline-versionVersion given to the existing schema1
spring.flyway.validate-on-migrateCheck checksums before migratingtrue
spring.flyway.out-of-orderAllow older versions to run after newer onesfalse
spring.flyway.clean-disabledBlock the command that deletes everythingtrue
spring.flyway.schemasSchemas Flyway managesThe 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”:

application.properties
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
  1. Export the current schema as V1__baseline.sql, so new environments can be built from scratch.
  2. Enable baseline-on-migrate. On the existing database, Flyway records version 1 as applied without running it.
  3. 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:

V6__grant_reporting_access.sql
GRANT SELECT ON books, authors TO ${reportingUser};
application-prod.properties
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:

application-dev.properties
spring.flyway.locations=classpath:db/migration,classpath:db/dev-data
src/main/resources/db/dev-data/V1000__sample_books.sql
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:

application.properties
management.endpoints.web.exposure.include=health,flyway
GET /actuator/flyway (shortened)
{
  "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:

MigrationTest.java
@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

FlywayLiquibase
Migration formatPlain SQL (or Java)SQL, XML, YAML or JSON “changesets”
Learning curveVery low: just SQL filesHigher: its own change format
Database independenceYou write SQL for your databaseAbstract changes can generate SQL for many databases
RollbackWrite a new forward migration (undo files in paid editions)Rollback blocks can be defined per change
Spring Boot supportBuilt inBuilt in
Best forTeams comfortable with SQL, one main databaseProducts 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=validate so mismatches between entities and schema fail fast.
  • Test migrations on the real database type, for example with Testcontainers.
  • Keep clean disabled 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

MistakeWhat goes wrongFix
Editing an applied migrationStartup fails with a checksum mismatchRevert the edit and add a new migration
One underscore instead of two (V1_create.sql)Flyway ignores or misreads the fileUse V1__create.sql
Keeping ddl-auto=update with FlywayHibernate and Flyway both change the schemaUse validate or none
Testing only on H2Scripts fail on the production databaseTest with the real database in a container
Two branches both adding V7Version conflict after mergingTimestamp versions, or renumber before merging
Missing database module (flyway-database-*)“Unsupported database” at startupAdd 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.