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 (createTable → dropTable, addColumn → dropColumn). 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
- Write the migration locally against a local PostgreSQL instance — not H2. If it runs locally with PostgreSQL, it runs in production.
- 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.
- 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.
- Apply in production with monitoring — watch Flyway/Liquibase logs and database metrics during migration. A long-running
ALTER TABLEis visible as a lock inpg_stat_activity. - Verify with Hibernate validation —
ddl-auto: validateat 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.