Posts

How to Use Flyway with Spring Boot

Oct 2, 2025
Catherine Edelveis
21.0

Flyway is a database migration tool for managing database schema changes using changelog files written in SQL.

This article will guide you through setting up and using Flyway with Spring Boot and Maven. You will learn how to:

  • Set up Flyway for PostgreSQL,
  • Perform various migration types,
  • Use conditional statements,
  • Run Flyway from CLI,
  • Apply database migrations in CI with GitHub Actions.

The code from this tutorial is available on GitHub.

Key Flyway Concepts and Features

Flyway is a straightforward, SQL-first solution for keeping relational DB schemas in sync across various environments. You can use it to run migrations at application start or work with it from CLI, a Docker container, or in CI.

You write migration scripts in SQL specifying the changes you want to apply. Flyway connects to the target database and runs the scripts in a strict order once, records their checksums in a special table, and fails if something is adrift: for instance, if the previously run script was modified.

What makes Flyway stand out as a DB versioning tool? Some of its essential features include:

  • Straightforward versioning model with forward-only migrations (rollbacks are offered as a commercial feature) written in familiar SQL;
  • Java-based migrations for describing changes not easily expressed in SQL;
  • Different types of migrations: versioned, repeatable, baseline, undo. We will look at them in more detail further on;
  • Support for conditional statements, placeholders, and DO Postgres files to specify the conditions that must be fulfilled before the migration is run. 

Let’s see how we can implement these powerful Flyway features in practice with Spring Boot!

Integrate Flyway into a Spring Boot Application

Prerequisites:

  • Java 17 or higher. As we are working with Spring Boot, I use Liberica JDK recommended by Spring.
  • Local PostgreSQL Server or Docker Compose if you want to spin up a database in a container. This demo uses the second option.
  • Your favorite IDE.

For this tutorial, I’m going to use a cyberpunk-themed demo project called Neurowatch. You can clone the project from GitHub to follow along, use your own app, or create a basic Spring Boot app.

First, we need to add the following dependencies on Flyway to the project:

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

In the application.properties file, we need to specify a URL, user name, password, and the driver for Postgres SQL. We also need to disable Hibernate because Flyway will manage the schema.

Last but not least, enable Flyway and specify the path to the directory where you will store the database migration files:

spring.datasource.url=jdbc:postgresql://localhost:5432/neurowatch-flyway
spring.datasource.username=neurowatch_user
spring.datasource.password=mypassword
spring.datasource.driver-class-name=org.postgresql.Driver

# Hibernate is disabled because Flyway will manage schema
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true

# Flyway
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration

All set, it’s time to write some migrations!

Write Migration Files

As mentioned above, Flyway supports four types of migrations: versioned, repeatable, baseline and undo. Let’s look at all of them.

Versioned Migrations

Versioned migrations are used for ordered schema changes like adding or removing tables, creating indexes, setting constraints, etc. They are applied exactly once in a strict version order.

When you run the migrations for the first time, Flyway creates a flyway_schema_history table, where it records each run migration with a checksum. If the migration file that was already run gets edited, the migration fails.

All Flyway migration files must follow a certain naming convention. The naming convention for versioned migrations is V<version>__<description>.sql. For example, V3__add_email_column.sql. Versions can be dotted or underscored like 1.2.0, 001_002.

Let’s start with writing our first migration. Create a file V1__create_tables.sql under the resources/db/migration directory and populate it with the familiar Postgres-specific SQL for creating a database schema:

CREATE TABLE civilian (
        id BIGINT PRIMARY KEY,
        legal_name VARCHAR(250),
        national_id VARCHAR(50),
        birth_date DATE,
        criminal_record BOOLEAN,
        under_surveillance BOOLEAN
);

CREATE TABLE cyberware (
        id BIGINT PRIMARY KEY,
        name VARCHAR(100),
        type VARCHAR(50),
        version VARCHAR(50)
);

CREATE TABLE implant_session (
        id BIGINT PRIMARY KEY,
        civilian_id BIGINT,
        cyberware_id BIGINT,
        installed_at DATE,
        installed_by VARCHAR(250),
        CONSTRAINT fk_civilian FOREIGN KEY (civilian_id) REFERENCES civilian(id),
        CONSTRAINT fk_cyberware FOREIGN KEY (cyberware_id) REFERENCES cyberware(id)
);

Now, let’s create one more migration file called V2__seed_sample_data.sql for populating the database with test data:

INSERT INTO civilian (id, legal_name, national_id, birth_date, criminal_record, under_surveillance) VALUES (1, 'Aelita Fang', 'IZ-0965437-BM', '1999-08-30', FALSE, FALSE);
INSERT INTO cyberware (id, name, type, version) VALUES (1, 'OptiSight X3', 'ocular', 'SZ 3000');
INSERT INTO implant_session (id, civilian_id, cyberware_id, installed_at, installed_by) VALUES (1, 1, 1, '2025-06-15', 'MP-129854');

Start the PostgreSQL instance and run the application. The migrations will be applied. Open your favorite database migration tool and verify that the tables were indeed created and populated.

Whenever you update your entities, you should create a new migration file describing the changes so that Flyway could update the schema accordingly.

For example, we added new columns, email to Civilian and manufacturer to Cyberware. Now, let’s create a V3__add_extra_columns.sql file with a following content:

ALTER TABLE civilian
    ADD COLUMN email VARCHAR(255);

ALTER TABLE cyberware
    ADD COLUMN manufacturer VARCHAR(100);

A good practice is to consider versioned migrations immutable. Don’t ever change them, create a new migration instead specifying required changes.

Repeatable Migrations

Repeatable migrations include the definitions you want to re-apply every time there are some changes. For example, you can use them to create or update views procedures or to perform bulk reloads of reference data.

Repeatable migrations are executed after all pending versioned migrations. A repeatable migration runs again whenever its checksum changes, i.e., the file content changes.

The naming convention is R__<description>.sql (no version).

Let’s create a repeatable migration called R__implant_summary_view.sql to get the statistics on all implants when we run the application. For that purpose, the migration will recreate a view joining three tables. You can add a comment with -- to specify what this migration does:

-- Repeatable migration: (re)create a view joining all three tables.

CREATE OR REPLACE VIEW v_implant_summary AS
SELECT
    s.id               AS session_id,
    c.legal_name,
    c.national_id,
    c.birth_date,
    c.criminal_record,
    c.under_surveillance,
    w.name             AS cyberware_name,
    w.type             AS cyberware_type,
    w.version          AS cyberware_version,
    s.installed_at,
    s.installed_by
FROM implant_session s
JOIN civilian      c ON c.id  = s.civilian_id
JOIN cyberware     w ON w.id  = s.cyberware_id;

We also need to add a placeholder with a timestamp. Flyway placeholders are text variables you define in configs and reference in migration files. Before a script is sent to the database, Flyway does a string substitution: it finds ${name} and replaces it with a value.

Add the following line to the top of the repeatable migration file:

-- ${build_timestamp}

Next, specify the timestamp variable in application.properties:

spring.flyway.placeholders.build_timestamp=${BUILD_TS:dev}

This way, the migration will run every time because the timestamp placeholder changes the checksum. So, Flyway will recreate this view and update the statistics on every migration run.

Baseline Migrations

Baseline migration is a single migration that represents the state of the database after all of the version migrations have been applied. They are useful for rapidly bootstrapping new environments by snapshotting the schema state at a point in time so there’s no need to apply a long chain of V files.

The naming convention is B<version>__<description>.sql. For example, B5__snapshot_after_V5.sql represents the state up to and including V5.

If you use flyway on a new database, it picks up the latest baseline file, marks every versioned migration which is lower than this baseline as ignored, and starts from the baseline snapshot of the database. However, if the database already has the flyway_history table, baseline files are skipped.

Note that baseline migrations do not conflict with the future V migrations, they simply speed up the installation of the database.

Let’s add a baseline migration to snapshot the database. Create a file called B4__baseline_after_schema_updates.sql. Here, we create the database schema, but this time, with all the changes we have introduced:

-- Snapshot after V3
CREATE TABLE IF NOT EXISTS civilian (
        id BIGINT PRIMARY KEY,
        legal_name VARCHAR(250),
        national_id VARCHAR(50),
        birth_date DATE,
        email VARCHAR(255),
        criminal_record BOOLEAN,
        under_surveillance BOOLEAN
);

CREATE TABLE IF NOT EXISTS cyberware (
        id BIGINT PRIMARY KEY,
        name VARCHAR(100),
        type VARCHAR(50),
        version VARCHAR(50),
        manufacturer VARCHAR(100)
);

CREATE TABLE IF NOT EXISTS implant_session (
        id BIGINT PRIMARY KEY,
        civilian_id BIGINT,
        cyberware_id BIGINT,
        installed_at DATE,
        installed_by VARCHAR(250),
        CONSTRAINT fk_civilian FOREIGN KEY (civilian_id) REFERENCES civilian(id),
        CONSTRAINT fk_cyberware FOREIGN KEY (cyberware_id) REFERENCES cyberware(id)
);

As a result, if you run Flyway with a new database, it will start with V4 migration, skipping previous versioned migrations.

Undo Migrations (Teams Edition)

Undo migrations are the commercial offering in Flyway Teams. They are aimed at the explicit reversal of a versioned migration with the same version as the undo migration.

The naming convention is U<version>__<description>.sql, where the version mirrors the V file version it undoes. For example, if we wanted to delete the test data from the database, we would write a file called U2__undo_seed_data.sql with the following content:

-- Undo for V2_seed_data

DELETE FROM implant_session WHERE id = 1;
DELETE FROM cyberware      WHERE id = 1;
DELETE FROM civilian       WHERE id = 1;

If you need to undo more versioned files, you write more undo migrations.

Undo migrations work under the presumption that the whole migration succeeded and should be undone. But in some cases, the migration can fail at some points. For instance, you have five statements, but the second statement has failed. Undo migrations will not be helpful in such situations.

Undo migrations are useful for fast local iteration, but production strategy should be based on forward-only fixes. In this case, you write new V files describing all required removals. This will help to avoid data loss and keep the audit clean.

So, the alternative to the undo file above would be a V4__purge_bad_seed_data.sql file with the following content:

-- Forward fix: remove test data inserted by V2

DELETE FROM implant_session WHERE id = 1;
DELETE FROM cyberware       WHERE id = 1;
DELETE FROM civilian        WHERE id = 1;

Executing Migrations Conditionally 

In some cases, you may want to run some checks before applying the migration: for instance, verify that the column doesn’t exist.

Flyway enables you to execute migrations conditionally, which means that the SQL inside a file decides whether the file does anything. 

There are three approaches to running migrations conditionally with Flyway:

  • Conditional statements,
  • Wrap the logic in a controlled block,
  • Use placeholders.

Let’s look at all of them.

Conditional statements can be seamlessly woven into SQL and are most optimal for checking whether the DDL already exists:

CREATE INDEX IF NOT EXISTS idx_email ON civilian(email);

For more complex checks, you can use controlled code blocks, for instance, use PostgreSQL’s DO $$ … $$, which executes an anonymous code block.

For example, let's add a new V migration V5__idx_birth_date_if_table_exists.sql for creating an index only when both table and column exist. Here, the SQL runs only if both conditions are met:

DO
$$
BEGIN
    -- Check table 'civilian' exists
    IF EXISTS (
        SELECT 1
        FROM   pg_catalog.pg_class  c
        JOIN   pg_catalog.pg_namespace n ON n.oid = c.relnamespace
        WHERE  c.relname = 'civilian'
        AND    c.relkind = 'r'          -- ordinary table
    ) THEN
        -- Check index is absent
        IF NOT EXISTS (
            SELECT 1
            FROM   pg_class c
            WHERE  c.relname = 'idx_civilian_birth_date'
        ) THEN
            CREATE INDEX idx_civilian_birth_date ON civilian (birth_date);
        END IF;
    END IF;
END
$$;

You can also use placeholders. We have already seen a placeholder with a timestamp in a repeatable migration file, but placeholders can also be used to create conditions.

Let’s add a condition on seeding the test data only if explicitly enabled. Declare the placeholder in the application.properties file, for instance, seed_demo_data. Set it to true or false:

spring.flyway.placeholders.seed_demo_data=true

After that, we need to reference this placeholder in the migration file. Create a new file V6__conditional_demo_seed.sql. In this file, we will also use the Postgres DO block, but instead of writing the lengthy precondition ourselves, we simply use the placeholder with the IF statement:

DO
$$
BEGIN
    IF '${seed_demo_data}' = 'true' THEN
        INSERT INTO civilian (id, legal_name, national_id, birth_date, email, criminal_record, under_surveillance)
        VALUES (1, 'Jax Ortega', 'XP‑771203‑VT', CURRENT_DATE, '[email protected]', FALSE, FALSE);

        INSERT INTO cyberware (id, name, type, version, manufacturer)
        VALUES (1, 'OptiSight X3', 'ocular', '1.1', 'SynthForge');

        INSERT INTO implant_session (id, civilian_id, cyberware_id, installed_at, installed_by)
        VALUES (1, 1, 1, CURRENT_DATE, 'MP-129854');
    END IF;
END
$$;

Flyway will substitute seed_demo_data with true or false at runtime.

As a result, in the non-production environment, you can get demo rows. On the other hand, you can set this placeholder to false for production, and in this case, Flyway will immediately exit leaving the database untouched.

Using Flyway from CLI

You can run Flyway from CLI as well. Let’s see how we can achieve that.

First, add the flyway-maven-plugin to pom.xml and specify the user, password, and URL. Optionally, specify the schema:

<build>
    <plugins>
        <plugin>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-maven-plugin</artifactId>
            <version>11.10.4</version>
            <configuration>
                <user>neurowatch_user</user>
                <password>mypassword</password>
                <url>jdbc:postgresql://localhost:5432/neurowatch-flyway</url>
                    <schemas>
                        <schema>neurowatch-flyway</schema>
                    </schemas>
            </configuration>
        </plugin>
    </plugins>
</build>

 After that, you can run the Flyway commands.

Flyway supports five basic commands to manage database migrations:

  • info prints information about the current database version, pending migrations, run migrations and so on.
  • migrate migrates a database schema to the current version.
  • baseline takes the snapshot of the current database schema. It is useful when you need to start using Flyway with the existing database.
  • validate validates current database schema against available migrations.
  • repair repairs metadata table.
  • clean drops all objects in the schema. Never use it in production!

 For instance, if you run

mvn flyway:info

You will see the summary of the statistics for the database migrations in the console. This command is very useful to know what's going on with your database migrations.

Running Database Migrations in CI with Flyway and GitHub Actions

Finally, let’s see how we can use Flyway to run database migrations in CI. For that purpose, we will use GitHub Actions.

Before we write the workflow file, we need to adjust the settings in the Flyway plugin. Namely, we need to add our placeholders:

<plugin>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-maven-plugin</artifactId>
    <version>11.10.4</version>
    <configuration>
        <user>neurowatch_user</user>
        <password>mypassword</password>
        <url>jdbc:postgresql://localhost:5432/neurowatch-flyway</url>
        <placeholders>
            <seed_demo_data>true</seed_demo_data>
            <build_timestamp>${env.BUILD_TS}</build_timestamp>
        </placeholders>
    </configuration>
</plugin>

Now, we can move on to the workflow file and discuss it step-by-step.

The workflow YAML will be automatically triggered whenever changes are pushed to the main branch of the application:

name: Flyway Migrations

on:
  workflow_dispatch:
  push:
    branches:
      - main

Next, we make sure that only one run is performed per branch/ref at a time, which prevents two migrations racing the same DB. A new run will queue instead of canceling the old one:

concurrency:
  group: flyway-migrations-${{ github.ref }}
  cancel-in-progress: false

After that, we spin up the PostgreSQL service. The database user and password are specified explicitly for the sake of the demo, but in a real setup, you should store credentials as GitHub Secrets and reference them in the password fields. The service also exposes the required ports and includes health-check options, so the job continues only after the database reports itself as ready.

jobs:
  migrate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: neurowatch-flyway
          POSTGRES_USER: neurowatch_user
          POSTGRES_PASSWORD: mypassword
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

Next, we specify job-wide environment variables applicable to all following steps: JDBC URL, credentials matching the service container, and the timestamp placeholder. This way, for repeatable migrations that reference ${build_timestamp}, Flyway will substitute this value. The value stays constant for the whole workflow run:

env:
  FLYWAY_URL:      jdbc:postgresql://localhost:5432/neurowatch-flyway
  FLYWAY_USER:     neurowatch_user
  FLYWAY_PASSWORD: mypassword
  FLYWAY_PLACEHOLDERS_BUILD_TIMESTAMP: "${{ github.run_id }}-${{ github.run_attempt }}"

The first job steps are checking out the repository and running a resource processing phase with Maven to filter config files and prepare assets. It doesn’t do anything if there’s nothing to process. After that, we install Liberica JDK 24, set JAVA_HOME, and enable Maven dependency caching for speed:

steps:
  - name: Checkout repository
    uses: actions/checkout@v4

  - name: Process Resources
    shell: bash
    run: |
      ./mvnw process-resources

  - name: Set up JDK 25
    uses: actions/setup-java@v4
    with:
      distribution: liberica
      java-version: '25'
      cache: maven

The next step runs the Flyway Maven plugin to apply pending migrations. It connects to the DB using the credentials specified above, creates flyway_schema_history if missing, runs V files in order, then any R files whose checksum changed. The -B flag means the batch mode (no interactive prompts).

  - name: Flyway migrate and validate
    run: mvn -B flyway:migrate

The following step verifies that the database matches the migration files (checksums/order). 

  - name: Flyway validate
    run: mvn -B flyway:validate

Once migrations are applied, the workflow runs the tests. If everything passes, the final stage deploys the application. In this demo, the last step is represented by a simple placeholder command, but in a real pipeline, you would replace it with your actual deployment process.

  - name:  Run tests
    run: ./mvnw test

  - name: Deploy app
    if: success()
    run: echo "Deploy your app here..."

That’s it! The whole file can be found on GitHub.

Conclusion

In this article, we examined using Flyway with Spring Boot and Maven for reliable database migrations locally and in the CI/CD pipeline.

Don’t forget to subscribe to our newsletter for a monthly digest of our articles and videos on Java development and news in the Java world!

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading