Database Migrations in Spring Boot — Flyway vs Liquibase and How to Set Up Either

by Eric Hanson, Backend Developer at Clean Systems Consulting

Why you need a migration tool

Without a migration tool, schema changes are applied manually, inconsistently, and without a record of what was applied where. ddl-auto: update in Hibernate is not a migration tool — it adds columns but doesn't rename them, change types, drop obsolete structures, or maintain a history. A migration tool provides:

  • Versioned history: every schema change is a numbered file, applied in order
  • Idempotency: each migration runs exactly once per database instance
  • Rollback path: migrations can include undo operations
  • Audit trail: the tool records which migrations have been applied and when
  • Environment consistency: development, staging, and production stay in sync

Both Flyway and Liquibase integrate with Spring Boot's auto-configuration — they run at startup, before Hibernate initializes, ensuring ddl-auto: validate sees the correct schema.

Flyway — SQL-native migrations

Flyway is simpler. Migrations are plain SQL files. The tool applies them in version order and tracks applied versions in a flyway_schema_history table.

Setup:

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<!-- For PostgreSQL-specific features in Flyway 10+ -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: false
    validate-on-migrate: true
    out-of-order: false

Migration file naming convention:

V{version}__{description}.sql
V1__create_orders_table.sql
V2__add_customer_email_to_orders.sql
V3__create_audit_entries_table.sql
V3.1__add_index_on_audit_entries.sql  # decimal versions for hotfixes

V is the prefix for versioned migrations (applied once, in order). R prefix for repeatable migrations (applied whenever they change — useful for views, stored procedures, and functions):

R__create_order_summary_view.sql  # re-applied whenever this file changes

A typical versioned migration:

-- V1__create_orders_table.sql
CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    customer_id BIGINT NOT NULL REFERENCES customers(id),
    status      VARCHAR(50) NOT NULL DEFAULT 'PENDING',
    total_cents BIGINT NOT NULL,
    currency    CHAR(3) NOT NULL DEFAULT 'USD',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by  VARCHAR(255) NOT NULL,
    version     BIGINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status) WHERE status NOT IN ('COMPLETED', 'CANCELLED');

Adding a column:

-- V4__add_shipping_address_to_orders.sql
ALTER TABLE orders ADD COLUMN shipping_address_id BIGINT REFERENCES shipping_addresses(id);
ALTER TABLE orders ADD COLUMN shipping_method VARCHAR(50);

CREATE INDEX idx_orders_shipping_address ON orders(shipping_address_id);

Adding a constraint safely:

-- V5__add_not_null_constraint_to_payment_method.sql
-- Safe approach: add NOT VALID first, validate separately
ALTER TABLE orders
    ADD CONSTRAINT orders_payment_method_not_null
    CHECK (payment_method IS NOT NULL) NOT VALID;

ALTER TABLE orders
    VALIDATE CONSTRAINT orders_payment_method_not_null;

Flyway runs each file in a transaction. If any statement fails, the migration is rolled back and Flyway marks it as failed. The application refuses to start until the failed migration is either repaired or manually resolved.

Configuration for different environments:

# application.yml — production settings
spring:
  flyway:
    enabled: true
    validate-on-migrate: true
    out-of-order: false  # strictly ordered in production

# application-dev.yml — development settings
spring:
  flyway:
    out-of-order: true  # allows applying migrations out of version order

out-of-order: true allows applying V3__ after V4__ has already been applied — useful in teams where branches develop simultaneously and migrations are merged in non-sequential order. Never enable in production.

Baseline for existing databases:

An existing database without migration history needs a baseline — a starting point that Flyway considers already applied:

spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1
    baseline-description: "Initial baseline"

Or manually: flyway baseline marks the current database state as version 1. All migrations above version 1 are applied going forward.

Liquibase — database-agnostic changesets

Liquibase uses changesets in XML, YAML, JSON, or SQL format. It tracks applied changesets by their id, author, and filename in a databasechangelog table.

Setup:

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>
spring:
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/db.changelog-master.yaml

Master changelog — the entry point:

# db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - include:
      file: db/changelog/changes/001-create-orders-table.yaml
  - include:
      file: db/changelog/changes/002-add-customer-email.yaml
  - include:
      file: db/changelog/changes/003-create-audit-entries.yaml

A changeset in YAML:

# db/changelog/changes/001-create-orders-table.yaml
databaseChangeLog:
  - changeSet:
      id: 001-create-orders-table
      author: engineering-team
      changes:
        - createTable:
            tableName: orders
            columns:
              - column:
                  name: id
                  type: BIGINT
                  autoIncrement: true
                  constraints:
                    primaryKey: true
              - column:
                  name: customer_id
                  type: BIGINT
                  constraints:
                    nullable: false
                    references: customers(id)
                    foreignKeyName: fk_orders_customer
              - column:
                  name: status
                  type: VARCHAR(50)
                  defaultValue: PENDING
                  constraints:
                    nullable: false
              - column:
                  name: total_cents
                  type: BIGINT
                  constraints:
                    nullable: false
              - column:
                  name: created_at
                  type: TIMESTAMPTZ
                  defaultValueComputed: NOW()
                  constraints:
                    nullable: false
        - addUniqueConstraint:
            tableName: orders
            columnNames: customer_id, order_reference
            constraintName: uq_orders_customer_reference
        - createIndex:
            tableName: orders
            columns:
              - column:
                  name: customer_id
            indexName: idx_orders_customer_id

Rollback support:

Liquibase automatically generates rollback for many built-in change types (createTabledropTable, addColumndropColumn). For operations without automatic rollback, define it explicitly:

- changeSet:
    id: 004-migrate-status-values
    author: engineering-team
    changes:
      - sql:
          sql: UPDATE orders SET status = 'PROCESSING' WHERE status = 'IN_PROGRESS'
    rollback:
      - sql:
          sql: UPDATE orders SET status = 'IN_PROGRESS' WHERE status = 'PROCESSING'

Database-specific SQL when needed:

- changeSet:
    id: 005-add-full-text-search
    author: engineering-team
    dbms: postgresql
    changes:
      - sql:
          sql: |
            ALTER TABLE orders ADD COLUMN search_vector tsvector;
            CREATE INDEX idx_orders_search ON orders USING GIN(search_vector);
    rollback:
      - sql:
          sql: |
            DROP INDEX IF EXISTS idx_orders_search;
            ALTER TABLE orders DROP COLUMN IF EXISTS search_vector;

dbms: postgresql restricts the changeset to PostgreSQL. On other databases, the changeset is skipped — enabling the same changelog to work across database vendors if needed.

Comparing the two

Choose Flyway when:

  • The team is comfortable writing SQL and wants to write migrations directly in SQL
  • Simplicity is a priority — Flyway's mental model is straightforward: numbered SQL files, applied in order
  • Database-specific features are used frequently (PostgreSQL window functions, partitioning, types)
  • The single-database-vendor constraint is acceptable (no need for cross-database portability)
  • Migration reviews happen in SQL — easier for DBAs and database-literate developers

Choose Liquibase when:

  • The application must support multiple database vendors (H2 for tests, PostgreSQL for production is common)
  • Rollback support is required — Liquibase's built-in rollback generation is more complete than Flyway's (Flyway Pro has undo migrations; Community edition doesn't)
  • The team prefers declarative change descriptions over raw SQL
  • The changelog must be auditable by non-SQL readers (YAML changesets are more readable to some audiences)
  • Integration with database diffing tools matters (Liquibase's diff command generates changesets from schema differences)

Both tools handle the core requirement — versioned, ordered, idempotent schema migrations integrated with Spring Boot — equally well. The choice is primarily about team preference and the specific features at the margins.

Testing migrations

Migrations should be tested against the target database, not just H2. A failing migration in production that passed H2 tests is a production incident:

@SpringBootTest
@Testcontainers
class MigrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired FlywayMigrationInitializer flywayInitializer;  // or LiquibaseMigrationInitializer

    @Test
    void allMigrationsApplySuccessfully() {
        // Spring Boot applies migrations before the test runs — if we reach here, they passed
        // Verify final schema state
    }

    @Test
    void schemaMatchesHibernateExpectation() {
        // Hibernate validates against the schema — validation failure means mismatch
        // ddl-auto: validate is enforced by @SpringBootTest loading the full context
    }
}

The migration test is a @SpringBootTest with a real PostgreSQL container. Spring Boot applies all migrations at startup; if any fail, the test fails with the migration error. This catches SQL syntax errors, constraint violations on existing data, and PostgreSQL-specific features not supported by H2.

The migration workflow that prevents production surprises

  1. Write the migration locally against a local PostgreSQL instance — not H2. If it runs locally with PostgreSQL, it runs in production.
  2. Apply to staging before production. Staging data is representative; some issues (constraint violations on existing data, long-running migrations on large tables) only appear with real data.
  3. Review the migration in CI — the migration test runs against a Testcontainers PostgreSQL instance. A green CI build means the migration applied successfully on the correct database type.
  4. Apply in production with monitoring — watch Flyway/Liquibase logs and database metrics during migration. A long-running ALTER TABLE is visible as a lock in pg_stat_activity.
  5. Verify with Hibernate validationddl-auto: validate at application startup confirms the schema matches entity mappings after migration.

The migration that's safest to deploy is one that's been applied successfully in at least two environments (local and staging) on real PostgreSQL data before reaching production.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Architecture Decisions Matter More Than Frameworks

Why do some apps crash after a minor update while others scale effortlessly? Often, it’s not the fancy framework—they’re just tools. The real magic (or disaster) starts with architecture.

Read more

How Singapore Scaleups Are Cutting Backend Overhead the Smart Way

You raised your Series A. You tripled your engineering team. Somehow, your backend ships slower than it did when there were four of you.

Read more

How the JVM Manages Memory — Heap Regions, GC Algorithms, and What to Tune

JVM garbage collection is not magic — it follows predictable patterns that determine latency, throughput, and memory footprint. Understanding the model lets you tune effectively instead of guessing at flags.

Read more

Your API Is Slower Than It Needs to Be and Pagination Is Probably Why

Unbounded list endpoints are one of the most common performance problems in production APIs — and one of the most preventable.

Read more