Every Spring Boot project needs a database migration tool. The two serious options are Flyway and Liquibase, and teams burn real time debating them.

Here’s the short version: both work well with Spring Boot, both are autoconfigured out of the box, and both will version your schema reliably. The difference is philosophy. Flyway is SQL files with a naming convention. Liquibase is an abstraction layer over your schema changes.

That difference sounds small. It shapes everything: how you write changes, how rollback works, how multi-database support works, and how much your team has to learn.

This article walks through both tools with working config, compares them feature by feature, and covers the case that matters most for modernization work: adding a migration tool to a database that’s never had one.

Why Versioned Migrations Matter

If you’ve ever inherited a database where the schema was maintained by hand, you know the failure mode. Production has a column that staging doesn’t. Nobody knows which index got added when, or why. Deploys involve a DBA running a script from a shared drive and hoping.

A migration tool fixes this by treating schema changes like code. Every change is a file, checked into git, applied in order, and recorded in a history table in the database itself. Any environment can be brought to the current schema by running the tool. New developers get a working local database with one command.

On modernization projects this is usually step one. You can’t upgrade Spring Boot, split a monolith, or move to a new database host with confidence if you can’t reproduce the schema. Versioned migrations turn “what does production actually look like” from an investigation into a query.

Both Flyway and Liquibase do this core job well. The question is which model fits your team.

How Flyway Works with Spring Boot

Flyway’s model: you write plain SQL files, name them by version, and Flyway applies any it hasn’t seen yet. It records what ran in a flyway_schema_history table and refuses to re-run or modify applied migrations.

Add the dependency:

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

<!-- Since Flyway 10, database support is modular.
     Add the module for your database, e.g. PostgreSQL: -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

Spring Boot manages the version and autoconfigures Flyway against your datasource. On startup, it scans classpath:db/migration and applies pending migrations before the application finishes booting.

Configuration in application.yml:

spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    validate-on-migrate: true
    # For adopting Flyway on an existing database:
    # baseline-on-migrate: true
    # baseline-version: 1

Migrations are SQL files with a strict naming convention: V<version>__<description>.sql. Note the double underscore.

-- src/main/resources/db/migration/V1__init.sql
CREATE TABLE customer (
    id          BIGSERIAL PRIMARY KEY,
    email       VARCHAR(255) NOT NULL UNIQUE,
    full_name   VARCHAR(255) NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT now()
);

CREATE INDEX idx_customer_email ON customer (email);

The next change is V2__add_customer_status.sql, then V3__..., and so on. Flyway also supports repeatable migrations (R__create_views.sql) that re-run whenever their content changes, which is useful for views and stored procedures.

When SQL isn’t enough, Flyway supports Java-based migrations in the community edition. You get a JDBC connection and full programmatic control:

package db.migration;

import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;

public class V4__backfill_customer_slugs extends BaseJavaMigration {

    @Override
    public void migrate(Context context) throws Exception {
        try (var stmt = context.getConnection().createStatement()) {
            // data transformation too awkward for plain SQL
            stmt.execute("UPDATE customer SET slug = lower(replace(full_name, ' ', '-'))");
        }
    }
}

That’s the whole tool. If your team thinks in SQL, there’s nothing new to learn. The SQL you’d write anyway is the migration.

The tradeoff: your migrations are written in one database’s dialect. If you need the same migrations to run against PostgreSQL in production and something else in a test environment or a second product deployment, you’re maintaining dialect-specific SQL yourself.

How Liquibase Works with Spring Boot

Liquibase’s model: you describe changes in a changelog, and Liquibase translates them into SQL for whatever database it’s pointed at. Changelogs can be YAML, XML, JSON, or plain SQL. History lives in a DATABASECHANGELOG table, with a companion DATABASECHANGELOGLOCK table to prevent concurrent runs.

Add the dependency:

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>

Spring Boot autoconfigures it the same way it does Flyway. Configuration in application.yml:

spring:
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/db.changelog-master.yaml
    # Run only changesets tagged for this environment:
    # contexts: production

The master changelog is the entry point, and it typically just includes other files:

# src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - include:
      file: db/changelog/001-init.yaml
  - include:
      file: db/changelog/002-add-customer-status.yaml

And a changeset file:

# src/main/resources/db/changelog/001-init.yaml
databaseChangeLog:
  - changeSet:
      id: 001-create-customer
      author: matt
      changes:
        - createTable:
            tableName: customer
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true
                  constraints:
                    primaryKey: true
              - column:
                  name: email
                  type: varchar(255)
                  constraints:
                    nullable: false
                    unique: true
              - column:
                  name: full_name
                  type: varchar(255)
                  constraints:
                    nullable: false
      rollback:
        - dropTable:
            tableName: customer

More verbose than the Flyway version, clearly. But notice two things.

First, there’s no dialect in that file. Point it at PostgreSQL, MySQL, or SQL Server and Liquibase generates the appropriate DDL. If you ship software that customers run on their own database, this is a big deal.

Second, the rollback block. For many change types Liquibase can generate the rollback automatically (it knows the inverse of createTable is dropTable), and for the rest you declare it alongside the change. Rollback is a first-class concept, not an afterthought.

Liquibase also has contexts and labels for environment-specific changesets, preConditions for guarding changes, and a diff command that compares two databases or a database against your changelog. And if you’d rather write SQL, Liquibase accepts formatted SQL changelogs too, so the abstraction is optional per changeset.

Flyway vs Liquibase: Comparison

FlywayLiquibase
Change formatSQL files (plus Java)YAML, XML, JSON, or SQL changelogs
PhilosophySQL-first, convention over abstractionDatabase-agnostic change abstraction
Spring Boot autoconfigYes, spring.flyway.*Yes, spring.liquibase.*
History trackingflyway_schema_history tableDATABASECHANGELOG + lock table
Rollback (free edition)No. Roll forward with a new migrationYes, by tag or count, with auto-generated or declared rollback
Targeted / advanced rollbackPaid (undo migrations)Paid (targeted rollback)
Dry run / SQL previewPaidFree (update-sql command)
Diff generationNot built inFree (diff, generate-changelog)
Java-based migrationsYes, freeYes, via custom change classes
Multi-database portabilityManual (per-dialect SQL)Built in via abstraction
Environment targetingPer-environment locations and placeholdersContexts and labels
Adopting on existing DBbaselinechangelog-sync
Learning curveMinimal if you know SQLChangelog syntax and concepts to learn

On paid tiers: both tools follow the same pattern. The free edition handles the core job completely, and the vendors monetize operational tooling on top: drift detection, policy checks, dry runs and undo for Flyway, targeted rollback and quality checks for Liquibase, plus support for certain older database versions. Most Spring Boot teams never hit those walls. Check the current edition matrices before you assume, because features have moved between tiers over the years.

One honest note on rollback: automatic rollback matters less in practice than it looks on a feature list. Rolling back a migration that dropped a column doesn’t bring the data back. Many teams on both tools converge on the same discipline: roll forward with a new corrective migration, and treat rollback as a tool for lower environments. If your organization has a hard requirement for scripted rollback in the release process, though, Liquibase gives you that in the free edition and Flyway doesn’t.

Migrations in CI/CD and Multiple Environments

The default Spring Boot behavior, migrations running at application startup, is fine for one service and a dev database. It gets uncomfortable in production.

The problems: your app’s startup now depends on DDL succeeding, a long-running migration can blow past your orchestrator’s health check window, and if a migration fails you have a half-started application to reason about. With multiple replicas starting at once, both tools handle locking (Flyway via database locks, Liquibase via its lock table), so you won’t get concurrent runs, but you will get replicas waiting on each other.

The pattern we see work on mature teams: run migrations as an explicit pipeline step before deploying the new application version, using the Flyway or Liquibase Maven/Gradle plugin or CLI. Then set spring.flyway.enabled: false (or the Liquibase equivalent) in production, or leave it on purely as a validation gate with nothing pending to apply.

Two rules keep multi-environment setups sane regardless of tool:

  1. Never edit an applied migration. Both tools checksum applied changes and will fail validation if a file changed after it ran. That error is annoying exactly once, and then it saves you repeatedly.
  2. Every schema change is backward compatible with the currently deployed code. Expand, migrate, contract: add the new column, deploy code that writes both, backfill, then drop the old column in a later migration. This is what makes zero-downtime deploys possible, and no tool does it for you.

For environment differences, Flyway uses placeholders and per-environment locations, while Liquibase uses contexts and labels. Liquibase’s version is more expressive. Flyway’s is harder to misuse. That tradeoff is the whole comparison in miniature.

Adopting a Migration Tool on a Legacy Database

Here’s the case that actually matters for modernization work. You’ve inherited a Spring Boot app (or a pre-Boot app you’re migrating) with a production database that has years of history and no migration tooling. You can’t start from V1__init.sql, because running CREATE TABLE against production would fail immediately.

Both tools solve this with a baseline: mark the current production schema as the starting point, and only version changes from here forward.

With Flyway, dump the current schema into V1__init.sql (so fresh environments can be built from nothing), then tell Flyway that existing databases are already at version 1:

spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1

On first run against production, Flyway writes a baseline row to its history table and skips everything at or below version 1. New databases run the full chain from V1. Existing ones only get V2 and up. One warning: leave baseline-on-migrate off once adoption is done, because it can mask a misconfigured datasource by silently baselining a database you didn’t intend to touch.

With Liquibase, generate a changelog from the live schema, then sync it:

# Generate a changelog that describes the current schema
# liquibase generate-changelog

# Then mark it as applied without executing it
# liquibase changelog-sync

changelog-sync writes every changeset in the changelog to DATABASECHANGELOG without running any of them. From that point the existing database and the changelog agree, and new changesets apply normally. Fresh environments just run the whole changelog for real.

Either way, the practical sequence is the same: snapshot the schema, baseline production, and enforce a hard rule that from this day forward no schema change happens outside a migration. The tooling takes an afternoon. The discipline is the part that requires management buy-in.

Which One Should You Pick

If you’re starting fresh and don’t have a forcing requirement, the honest answer is that either works and you should stop debating. But there are real signals that point one way or the other.

Pick Flyway when:

  • Your team is SQL-fluent and wants migrations to be exactly the SQL that runs
  • You target one database engine and expect that to stay true
  • You want the smallest possible learning curve and surface area
  • Roll-forward is your recovery strategy anyway, so free-edition rollback doesn’t matter

Pick Liquibase when:

  • You ship to multiple database engines, or customers run your software on their own database
  • Your release process requires scripted rollback without a paid license
  • You want diff and changelog generation built into the free tool
  • You value declarative changelogs with preconditions and per-environment contexts, and the team will actually learn them

The team dimension matters more than the feature list. Flyway’s simplicity is only a win if plain SQL covers your needs, and Liquibase’s power is only a win if the team invests in learning it. A half-understood Liquibase setup is worse than a well-run Flyway one, and vice versa.

And if you’re already using one of them: switching tools is almost never worth the disruption without a concrete driver like a new multi-database requirement. Both track history in their own metadata table, so a switch is mechanically just another baseline, but “mechanically possible” and “good use of a sprint” are different things.

When to Get Help

Adding Flyway or Liquibase to a greenfield Spring Boot app is an hour of work, and this article covers most of what you need.

The hard version is the legacy one: a production database with years of undocumented drift, multiple environments that don’t quite match, stored procedures nobody has touched since the last hiring cycle, and an application upgrade (Spring Boot 3 to 4, Java 8 to 21) happening at the same time. Baselining that safely, reconciling environment drift, and setting up a pipeline where migrations run before deploys instead of during startup takes experience with the failure modes.

We do this work regularly. If your team is untangling a legacy database as part of a broader Spring Boot or Java modernization, reach out and we can walk through your specific situation.

Frequently Asked Questions

Does Spring Boot run Flyway or Liquibase migrations automatically?

Yes. If either tool is on the classpath and a datasource is configured, Spring Boot autoconfigures it and runs pending migrations at application startup, before the rest of the context finishes loading. You can disable this and run migrations from your CI/CD pipeline instead, which is what most teams do once they have more than one environment.

Which features of Flyway and Liquibase require a paid edition?

Flyway’s community edition covers versioned and repeatable migrations, but undo migrations, dry runs, and drift detection sit in the paid tiers. Liquibase’s open source edition includes basic rollback by tag or count, SQL preview via update-sql, and diff generation, while targeted rollback, policy checks, and advanced drift reporting are paid. Most Spring Boot teams get by fine on the free editions of either tool.

Can I introduce Flyway or Liquibase to a database that already exists?

Yes, and this is the normal case on modernization projects. Flyway handles it with a baseline: you tell it the current schema is version 1, and it only applies migrations above that. Liquibase does the same with changelog-sync, which marks a generated changelog as already applied without executing it. Either way, the existing schema becomes your starting point and every change after that is versioned.

Can I switch from Flyway to Liquibase later, or the other way around?

You can, because both tools only track history in their own metadata table and don’t own your schema. The switch is essentially the same as adopting a tool on a legacy database: baseline the current state in the new tool, stop running the old one, and drop its history table when you’re confident. It’s rarely worth doing without a concrete reason like a new multi-database requirement.

Java Modernization Readiness Assessment

15 questions your team should answer before starting a migration. Takes 10 minutes. Could save you months.