MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Database Migrations with Flyway and Liquibase

Manage database schema changes: versioned migrations, rollback strategies, and CI/CD integration.

DatabaseMigrationsFlywayLiquibase

By MinhVo

Introduction

Database schema changes are the most dangerous deployments in software engineering. While application code can be rolled back by redeploying a previous version, schema changes mutate persistent state—dropped columns destroy data, renamed tables break queries, and altered types lose precision. Without a disciplined migration framework, teams resort to manual SQL scripts executed via SSH during maintenance windows, a process that's error-prone, unrepeatable, and terrifying at 2 AM.

Database migration tools like Flyway and Liquibase solve this by treating schema changes as versioned, ordered, reproducible scripts that are applied automatically during deployment. Each migration has a unique version number, is tracked in a metadata table, and is applied exactly once. If a migration fails, the tool detects the inconsistency and prevents further deployments until the issue is resolved.

This guide provides a deep technical comparison of Flyway and Liquibase, covering their architectures, configuration, rollback strategies, CI/CD integration patterns, and real-world deployment workflows for teams of all sizes.

Database migration tools comparison

Understanding Migration Tools: Core Concepts

How Migration Versioning Works

Both Flyway and Liquibase maintain a metadata table in your database (typically flyway_schema_history or databasechangelog) that records every applied migration. When the tool runs, it compares the metadata table against the migration files in your project. Any files with version numbers higher than the latest applied migration are candidates for execution.

The metadata table stores: the migration version (or checksum), the description, the script filename, who applied it, when it was applied, and whether it succeeded. This creates a complete audit trail of every schema change and enables reliable deployment across environments—development, staging, and production all converge to the same schema state.

Flyway's Approach: SQL-Native Versioning

Flyway uses a simple naming convention for migration files: V{version}__{description}.sql. The version number determines execution order. Flyway is SQL-first—it expects raw SQL scripts and executes them in version order. This simplicity is Flyway's greatest strength: there's no abstraction layer between you and the database, no XML to learn, and no generated SQL to debug.

src/main/resources/db/migration/
  V1__create_users_table.sql
  V2__add_email_column.sql
  V3__create_orders_table.sql
  V3.1__add_index_on_email.sql

Flyway also supports repeatable migrations (R__description.sql) that re-run whenever their checksum changes. This is useful for views, stored procedures, and seed data that should be recreated on every deployment.

Liquibase's Approach: Database-Agnostic Changesets

Liquibase uses an abstract changelog format (XML, YAML, or JSON) that describes schema changes as database-agnostic "changesets." Each changeset contains one or more "changes" (like createTable, addColumn, createIndex) that Liquibase translates to the appropriate SQL dialect for your database.

<changeSet id="1" author="dev">
  <createTable tableName="users">
    <column name="id" type="BIGINT" autoIncrement="true">
      <constraints primaryKey="true"/>
    </column>
    <column name="email" type="VARCHAR(255)">
      <constraints nullable="false" unique="true"/>
    </column>
    <column name="name" type="VARCHAR(255)"/>
  </createTable>
</changeSet>

This abstraction enables supporting multiple database engines from a single changelog, which is valuable for teams that develop against H2 or SQLite locally but deploy to PostgreSQL or MySQL in production. However, the abstraction layer can obscure the actual SQL being executed, making debugging harder.

CI/CD pipeline for database migrations

Architecture and Design Patterns

Migration File Organization

Flyway supports multiple migration locations and naming patterns:

db/migration/            # Default location
  V1__create_users.sql
  V2__add_email.sql
 
db/repeatable/           # Repeatable migrations
  R__create_view_active_users.sql
  R__seed_default_roles.sql
 
db/callbacks/            # Lifecycle callbacks
  beforeEach.sql
  afterEach.sql

Liquibase organizes changesets in a master changelog that includes sub-changelogs:

<!-- master-changelog.xml -->
<databaseChangeLog>
  <include file="changelogs/001-create-users.xml"/>
  <include file="changelogs/002-add-email.xml"/>
  <include file="changelogs/003-create-orders.xml"/>
  <include file="changelogs/004-seed-data.xml"/>
</databaseChangeLog>

Context-Based Execution

Liquibase supports "contexts" that control which changesets run in which environments. This is useful for seed data that only runs in development or test environments:

<changeSet id="seed-users" author="dev" context="dev,test">
  <insert tableName="users">
    <column name="email" value="admin@example.com"/>
    <column name="name" value="Admin"/>
  </insert>
</changeSet>

Flyway achieves similar behavior through profiles and conditional scripts using SQL comments with Flyway placeholders.

Precondition Checking

Liquibase supports preconditions that validate database state before applying changesets:

<changeSet id="add-status-column" author="dev">
  <preconditions onFail="MARK_RAN">
    <not>
      <columnExists tableName="users" columnName="status"/>
    </not>
  </preconditions>
  <addColumn tableName="users">
    <column name="status" type="VARCHAR(20)" defaultValue="active"/>
  </addColumn>
</changeSet>

Flyway doesn't have built-in preconditions but supports conditional logic within SQL scripts using database-specific constructs like DO $$ ... IF NOT EXISTS ... END $$ in PostgreSQL.

Rollback Strategies

Flyway supports undo migrations (U{version}__undo_description.sql) in the paid Teams edition. In the open-source version, you handle rollbacks manually by writing reverse migration scripts.

Liquibase has built-in rollback support. Each changeset can define a rollback block:

<changeSet id="add-email" author="dev">
  <addColumn tableName="users">
    <column name="email" type="VARCHAR(255)"/>
  </addColumn>
  <rollback>
    <dropColumn tableName="users" columnName="email"/>
  </rollback>
</changeSet>

Liquibase can auto-generate rollbacks for many changes (like addColumn, createTable), but complex data transformations require explicit rollback definitions.

Step-by-Step Implementation

Flyway Setup with Spring Boot

<!-- pom.xml -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>
# application.yml
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true
    validate-on-migrate: true
    out-of-order: false
    table: flyway_schema_history
    baseline-version: 0
-- V1__create_users_table.sql
CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE INDEX idx_users_email ON users (email);
-- V2__add_user_roles.sql
CREATE TABLE roles (
  id BIGSERIAL PRIMARY KEY,
  name VARCHAR(50) NOT NULL UNIQUE
);
 
CREATE TABLE user_roles (
  user_id BIGINT REFERENCES users(id),
  role_id BIGINT REFERENCES roles(id),
  PRIMARY KEY (user_id, role_id)
);

Liquibase Setup with Spring Boot

<!-- pom.xml -->
<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>
# application.yml
spring:
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/master.xml
    contexts: dev
<!-- db/changelog/master.xml -->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
    http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
 
  <include file="changelogs/001-create-users.xml"/>
  <include file="changelogs/002-add-roles.xml"/>
</databaseChangeLog>
<!-- changelogs/001-create-users.xml -->
<databaseChangeLog>
  <changeSet id="1" author="dev">
    <createTable tableName="users">
      <column name="id" type="BIGINT" autoIncrement="true">
        <constraints primaryKey="true"/>
      </column>
      <column name="email" type="VARCHAR(255)">
        <constraints nullable="false" unique="true"/>
      </column>
      <column name="name" type="VARCHAR(255)">
        <constraints nullable="false"/>
      </column>
      <column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()"/>
      <column name="updated_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()"/>
    </createTable>
    <createIndex tableName="users" indexName="idx_users_email">
      <column name="email"/>
    </createIndex>
  </changeSet>
</databaseChangeLog>

Flyway CLI Usage

# Install Flyway CLI
brew install flyway
 
# Run migrations
flyway -url=jdbc:postgresql://localhost:5432/mydb migrate
 
# Check migration status
flyway -url=jdbc:postgresql://localhost:5432/mydb info
 
# Validate migrations (check for conflicts)
flyway -url=jdbc:postgresql://localhost:5432/mydb validate
 
# Repair metadata table (fix failed migrations)
flyway -url=jdbc:postgresql://localhost:5432/mydb repair
 
# Clean database (development only!)
flyway -url=jdbc:postgresql://localhost:5432/mydb clean
 
# Baseline existing database
flyway -url=jdbc:postgresql://localhost:5432/mydb baseline

Liquibase CLI Usage

# Install Liquibase CLI
brew install liquibase
 
# Run migrations
liquibase --url=jdbc:postgresql://localhost:5432/mydb update
 
# Check status
liquibase --url=jdbc:postgresql://localhost:5432/mydb status
 
# Generate diff between database and changelog
liquibase --url=jdbc:postgresql://localhost:5432/mydb diff
 
# Rollback last N changesets
liquibase --url=jdbc:postgresql://localhost:5432/mydb rollback-count 3
 
# Rollback to specific tag
liquibase --url=jdbc:postgresql://localhost:5432/mydb rollback-tag v1.0
 
# Generate SQL without executing
liquibase --url=jdbc:postgresql://localhost:5432/mydb updateSQL

Real-World Use Cases

Use Case 1: Microservice Migration Strategy

A platform with 12 microservices, each with its own database, needed consistent migration management. Each service used Flyway with service-specific migration directories:

user-service/src/main/resources/db/migration/
order-service/src/main/resources/db/migration/
payment-service/src/main/resources/db/migration/

The CI/CD pipeline ran flyway validate on every PR to catch migration conflicts before merge. flyway migrate ran automatically during deployment, and flyway info output was included in deployment logs for audit purposes. The team standardized on sequential version numbers (V1, V2, V3) with timestamps as descriptions, avoiding merge conflicts from parallel development.

Use Case 2: Multi-Database Application

An enterprise application needed to support both PostgreSQL (production) and H2 (testing). Liquibase's database-agnostic changelogs made this straightforward:

<changeSet id="add-jsonb-column" author="dev">
  <preconditions onFail="MARK_RAN">
    <dbms type="postgresql"/>
  </preconditions>
  <sql>
    ALTER TABLE documents ADD COLUMN metadata JSONB DEFAULT '{}'::jsonb;
  </sql>
</changeSet>
 
<!-- Fallback for H2 -->
<changeSet id="add-jsonb-column-h2" author="dev">
  <preconditions onFail="MARK_RAN">
    <dbms type="h2"/>
  </preconditions>
  <addColumn tableName="documents">
    <column name="metadata" type="CLOB" defaultValue="{}"/>
  </addColumn>
</changeSet>

Use Case 3: Legacy Database Baseline

A team inherited a production database with 200+ tables and no migration history. They needed to start using Flyway without disrupting the existing database.

# Create baseline at current schema version
flyway baseline -baselineVersion=100
 
# Start new migrations from version 101
# V101__add_audit_columns.sql

The baseline command marks version 100 as the starting point. Only migrations V101 and later are applied. The existing schema is preserved, and future changes are tracked.

Best Practices for Production

  1. Never edit an applied migration: Once a migration has been applied to any environment (including development), it must never be modified. Changing a deployed migration's content changes its checksum, causing validation failures. Create a new migration to fix issues.

  2. Use meaningful version numbers: Flyway's V{version}__{description} convention works best with sequential numbers. Avoid embedding dates in version numbers as they cause ordering issues when parallel development creates out-of-sequence migrations.

  3. Separate schema changes from data migrations: DDL changes (CREATE TABLE, ALTER TABLE) and DML changes (INSERT, UPDATE) should be in separate migration files. DDL changes are typically fast and atomic; DML changes may be long-running and need batching.

  4. Include rollbacks for critical changes: Even if your primary flow is forward-only, having rollback scripts for destructive changes (DROP TABLE, DROP COLUMN) provides a safety net. Liquibase makes this natural; Flyway requires manual rollback scripts.

  5. Validate in CI/CD before deployment: Run flyway validate or liquibase checks in your CI pipeline to detect migration conflicts, missing dependencies, and checksum mismatches before they reach production.

  6. Use repeatable migrations for views and functions: Flyway's R__ prefix and Liquibase's runAlways attribute ensure that views, stored procedures, and functions are always current. This avoids drift between environments.

  7. Test migrations against a production clone: Run migrations against a copy of production data before deploying. This catches performance issues (long-running ALTER TABLE), constraint violations, and data type incompatibilities.

  8. Keep migrations small and focused: Each migration should make one logical change. This simplifies debugging, makes rollbacks more granular, and reduces the blast radius of failures.

Common Pitfalls and Solutions

PitfallImpactSolution
Editing applied migrationsChecksum validation failures, broken deploymentsNever modify deployed files; create new migrations
Long-running DDL in productionTable locks, application downtimeUse CONCURRENTLY, expand-contract, or online schema change tools
Merge conflicts in version numbersCI/CD pipeline blocks, developer confusionUse sequential numbering or timestamp-based versions
Missing rollback scriptsCannot undo destructive changes safelyWrite rollback scripts for every destructive migration
Not testing on production dataMigrations fail on edge cases in real dataTest with production data clones before deploying
Out-of-order migrations in shared environmentsSchema state inconsistencies between developersUse Flyway's out-of-order flag or Liquibase's context-based execution

Performance Optimization

Parallel Migration Execution

For applications with many independent schema changes, Flyway Teams supports parallel migration execution:

# flyway.conf
flyway.parallel=true

Liquibase supports distributed locking for concurrent execution across multiple application instances:

<!-- liquibase.properties -->
liquibase.hub.mode=OFF

Efficient Data Backfills

-- V5__backfill_user_status.sql (Flyway)
-- Idempotent batch backfill
UPDATE users
SET status = 'active'
WHERE status IS NULL
AND id IN (
  SELECT id FROM users WHERE status IS NULL LIMIT 10000
);
<!-- Liquibase custom SQL with batching -->
<changeSet id="backfill-status" author="dev">
  <sql>
    UPDATE users SET status = 'active'
    WHERE status IS NULL
    AND id IN (SELECT id FROM users WHERE status IS NULL LIMIT 10000);
  </sql>
  <rollback/>
</changeSet>

Comparison with Alternatives

FeatureFlywayLiquibaseAlembic (Python)Rails ActiveRecorddbmate
LanguageJava/CLIJava/CLIPythonRubyGo
FormatSQLXML/YAML/SQLPythonRubySQL
Rollback supportPaidFreeFreeFreeFree
Multi-databaseYesYes (best)YesLimitedYes
Learning curveLowMediumLowLowVery low
CI/CD integrationExcellentExcellentGoodGoodGood
Dry-run supportNoYes (updateSQL)YesNoYes
CommunityLargeLargeLargeVery largeSmall

Advanced Patterns

Flyway Callbacks

-- beforeMigrate.sql
SET search_path TO public;
 
-- afterEachMigrate.sql
ANALYZE;

Liquibase Preconditions for Safe Migrations

<changeSet id="safe-add-column" author="dev">
  <preconditions onFail="MARK_RAN">
    <not>
      <columnExists tableName="users" columnName="phone"/>
    </not>
  </preconditions>
  <addColumn tableName="users">
    <column name="phone" type="VARCHAR(20)"/>
  </addColumn>
</changeSet>

Generating Changelogs from Existing Schema

# Liquibase: generate changelog from existing database
liquibase --url=jdbc:postgresql://localhost:5432/mydb generateChangeLog
 
# Flyway: doesn't have built-in generation, but tools like
# SchemaSpy or pg_dump can help document existing schemas

Testing Strategies

# CI pipeline migration validation
flyway -url=jdbc:postgresql://localhost:5432/test migrate
flyway -url=jdbc:postgresql://localhost:5432/test validate
flyway -url=jdbc:postgresql://localhost:5432/test info
 
# Liquibase: generate SQL for review
liquibase --url=jdbc:postgresql://localhost:5432/test updateSQL > migration-preview.sql
 
# Test rollback
liquibase rollback-count 1
liquibase update

Future Outlook

Database migration tooling is evolving toward GitOps workflows where schema changes are managed through pull requests with automated preview environments. PlanetScale's database branching model allows developers to create schema branches, test changes in isolation, and deploy via merge requests.

Liquibase has been investing in the "Liquibase Checks" feature for policy enforcement—automated rules that validate migrations against team standards (like "no DROP TABLE" or "all columns must have defaults") before they're applied. Flyway's Teams edition is adding similar governance features.

The broader trend is toward treating database schema as code: version-controlled, peer-reviewed, automatically tested, and deployed through CI/CD pipelines. Both Flyway and Liquibase are well-positioned for this future.

Migration Testing Strategies

Test database migrations in a staging environment that mirrors production before applying them to production. Use a copy of the production database schema to verify that migrations run without errors. Test both the upgrade path (applying the migration) and the downgrade path (reversing it) to ensure rollback capability. Use Flyway's flyway validate or Liquibase's liquibase validate commands to check migration files for syntax errors before execution. Implement automated migration testing in your CI pipeline to catch issues early in the development cycle.

Zero-Downtime Migration Patterns

Achieve zero-downtime database migrations using expand-and-contract patterns. In the expand phase, add new columns or tables without removing existing ones. Deploy application code that writes to both old and new schemas (dual-write pattern). Backfill existing data into the new schema during low-traffic periods. In the contract phase, switch reads to the new schema, remove dual-write logic, and finally drop the old columns or tables. Use feature flags to control the migration timeline and enable instant rollback if issues arise.

Conclusion

Choosing between Flyway and Liquibase depends on your team's priorities. Flyway's SQL-first approach is simpler to learn, more transparent (you see exactly what SQL runs), and ideal for teams comfortable with raw SQL. Liquibase's abstract changelog format is better for multi-database support, built-in rollbacks, and teams that prefer declarative schema definitions.

Key takeaways:

  1. Both tools are production-proven: Flyway and Liquibase are used by thousands of companies worldwide. Choose based on your team's workflow preferences, not technical superiority.
  2. SQL scripts vs abstract changelogs: Flyway gives you direct SQL control. Liquibase gives you database abstraction. Pick the one that matches your team's expertise.
  3. Rollback support matters: Liquibase's built-in rollback support provides a safety net. If using Flyway, budget time for manual rollback scripts.
  4. CI/CD integration is essential: Validate migrations in your pipeline before deploying. Catch conflicts, checksum mismatches, and syntax errors early.
  5. Test with production data: Migration performance on test data is meaningless. Always validate against production-sized datasets.

Start by integrating your chosen tool into your CI/CD pipeline, establishing a migration naming convention, and running your first migration against a production clone. The investment in disciplined schema management pays dividends in deployment confidence and operational reliability.