Skip to content

Database Version Control: Flyway and Liquibase Guide

DodaTech Updated 2026-06-22 8 min read

In this tutorial, you'll learn about Database Version Control: Flyway and Liquibase Guide. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Database version control is the practice of managing database schema changes through version-controlled Migration scripts -- ensuring that database schemas evolve consistently across environments, can be rolled back when needed, and are auditable through a complete change history.

What You'll Learn

You will implement Flyway and Liquibase for schema versioning, integrate migrations into CI/CD pipelines, manage rollback strategies, handle merge conflicts, and adopt zero-downtime Migration patterns for production deployments.

Why Database Version Control Matters

Schema changes deployed without versioning cause drift between environments and difficult-to-debug failures. Doda Browser deploys database changes alongside application code; using Flyway eliminated schema drift incidents, which previously caused 3-4 production incidents per quarter.

Version Control Learning Path

flowchart LR
  A[Database Design] --> B[Migration Tools]
  B --> C[Database Version Control]
  C --> D[CI/CD Integration]
  C:::current
  classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px
â„šī¸ Info

Prerequisites: Familiarity with database migrations and basic SQL DDL. Understanding of PostgreSQL or MySQL.

Tool Comparison

Feature Flyway Liquibase Alembic golang-migrate
File format SQL XML/YAML/JSON/SQL Python SQL
Rollback Paid only Built-in Built-in Built-in
CI/CD friendly Excellent Good Good Good
Repeatable migrations Yes Yes No No
Java-based Yes Yes No No
Undo support Flyway Teams Built-in downgrade() Down Migration

Flyway in Practice

Project Structure

db/
  migrations/
    V1__create_users.sql
    V2__add_orders.sql
    V3__add_last_login.sql
    V4__create_indexes.sql
  repeatable/
    R__seed_lookup_tables.sql
    R__grant_permissions.sql

Migration Files

-- V1__create_users.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users (email);
-- V2__add_orders.sql
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    total DECIMAL(12,2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    shipping_address TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_orders_status ON orders (status);

Configuration

# flyway.toml (Flyway 10+)
[environments.default]
url = "jdbc:postgresql://localhost:5432/mydb"
user = "app_user"
password = "${DB_PASSWORD}"
schemas = ["public"]

[flyway]
locations = ["filesystem:db/migrations", "filesystem:db/repeatable"]
baselineOnMigrate = true
baselineVersion = 0

CI/CD Integration

# GitHub Actions - Flyway migration step
jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Flyway migrations
        run: |
          docker run --rm \
            -v ${{ github.workspace }}/db:/flyway/project/db \
            -e FLYWAY_URL=jdbc:postgresql://${{ secrets.DB_HOST }}:5432/${{ secrets.DB_NAME }} \
            -e FLYWAY_USER=${{ secrets.DB_USER }} \
            -e FLYWAY_PASSWORD=${{ secrets.DB_PASSWORD }} \
            flyway/flyway:10 migrate

Liquibase in Practice

Changelog Structure

<?xml version="1.0" encoding="UTF-8"?>
<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 individual changelogs -->
    <include file="changelogs/001-create-users.xml"/>
    <include file="changelogs/002-create-orders.xml"/>
    <include file="changelogs/003-add-indexes.sql"/>
</databaseChangeLog>

Changeset with Rollback

<?xml version="1.0" encoding="UTF-8"?>
<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">

    <changeSet id="1" author="dodatech">
        <createTable tableName="users">
            <column name="id" type="bigint" autoIncrement="true">
                <constraints primaryKey="true"/>
            </column>
            <column name="name" type="varchar(100)">
                <constraints nullable="false"/>
            </column>
            <column name="email" type="varchar(255)">
                <constraints nullable="false" unique="true"/>
            </column>
        </createTable>

        <rollback>
            <dropTable tableName="users"/>
        </rollback>
    </changeSet>

    <changeSet id="2" author="dodatech">
        <addColumn tableName="users">
            <column name="last_login" type="timestamp"/>
        </addColumn>
        <rollback>
            <dropColumn tableName="users" columnName="last_login"/>
        </rollback>
    </changeSet>
</databaseChangeLog>

Liquibase Commands

# Deploy pending changesets
liquibase --changeLogFile=db/changelog-master.xml update

# Rollback last 2 changesets
liquibase --changeLogFile=db/changelog-master.xml rollbackCount 2

# Rollback to specific date
liquibase --changeLogFile=db/changelog-master.xml rollbackToDate 2026-06-20

# Generate diff between databases
liquibase --changeLogFile=db/changelog-master.xml diff \
  --referenceUrl=jdbc:postgresql://prod-host/proddb

Merge Conflicts in Migrations

When multiple developers create migrations with the same version number.

Prevention

# Require unique migration versions in CI
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Check for duplicate migration versions
        run: |
          db/migrations/*.sql | grep -oP 'V\d+' | sort | uniq -d
          if [ $? -eq 0 ]; then
            echo "Duplicate migration versions found!"
            exit 1
          fi

Resolution Strategy

-- Developer A creates V3__add_profile.sql
-- Developer B creates V3__add_settings.sql (conflict!)

-- Resolution: Rename one to V4 and reorder
-- V3__add_profile.sql stays as-is
-- V4__add_settings.sql (renamed, depends on V3 only if needed)

Repeatable Migrations

Run every time their checksum changes. Useful for views, functions, and grants.

-- R__create_user_view.sql
CREATE OR REPLACE VIEW active_users AS
SELECT id, name, email, created_at
FROM users
WHERE deleted_at IS NULL;

-- R__grant_permissions.sql
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO readwrite_role;

Zero-Downtime Migration with Version Control

-- V5__add_preferred_name.sql
-- Step 1: Add column as nullable (no downtime)
ALTER TABLE users ADD COLUMN preferred_name VARCHAR(100);

-- V6__backfill_preferred_name.sql
-- Step 2: Backfill in batches (run separately, may take hours)
DO $$
DECLARE
    batch_size INT := 10000;
BEGIN
    LOOP
        UPDATE users
        SET preferred_name = name
        WHERE preferred_name IS NULL
        AND ctid IN (
            SELECT ctid FROM users
            WHERE preferred_name IS NULL
            LIMIT batch_size FOR UPDATE SKIP LOCKED
        );
        EXIT WHEN NOT FOUND;
        COMMIT;
    END LOOP;
END $$;

-- V7__make_preferred_name_not_null.sql
-- Step 3: Add NOT NULL constraint (run after backfill)
ALTER TABLE users ALTER COLUMN preferred_name SET NOT NULL;

Common Version Control Errors

1. Editing a Migration After It Has Been Applied

Flyway detects checksum changes and refuses to apply. Never edit applied migrations. Create a new Migration to fix issues.

2. No Rollback Scripts

Without rollback scripts, reverting a deployment requires manual intervention. Every Migration should have a corresponding rollback.

3. Running Migrations Automatically on Application Startup

Auto-Migration on startup causes problems with multiple instances (race conditions, duplicate migrations). Use a separate Migration step in CI/CD.

4. Ignoring Migration Order Dependencies

If V5 depends on data created in V4, they must run in order. Test the entire Migration chain from scratch on a fresh database.

5. Not Testing Migrations on Production-Scale Data

A Migration that takes 2 seconds on a development database may take 2 hours on production. Test with production-sized datasets in staging.

6. No Locking Strategy for Concurrent Migrations

Two deployment pipelines running migrations simultaneously can corrupt the schema history. Use database-level locks or CI/CD environment checks.

7. Storing Secrets in Migration Files

Passwords, API keys, and other secrets in Migration files are exposed in version control. Use environment variables or secrets management.

Practice Questions

1. What is the difference between versioned and repeatable migrations in Flyway?

Versioned migrations (V1, V2) run once in order. Repeatable migrations (R__) run every time their checksum changes, useful for views and grants.

2. How does Liquibase handle rollbacks?

Each changeset can include a <rollback> section defining how to revert the change. Liquibase also supports automatic rollback generation for simple operations.

3. How do you handle Migration conflicts in a team?

Use CI to check for duplicate version numbers. Communicate before creating migrations. Rename conflicting migrations and reorder.

4. What should you do if a Migration fails in production?

Stop the deployment, assess the failure (partial apply vs no apply), run the rollback script, fix the Migration, re-test, and re-deploy.

5. Challenge: Set up a database version control pipeline.

Design a pipeline for a team of 5 developers deploying to staging and production. Answer: (1) Use Flyway with SQL migrations. (2) CI checks for duplicate versions and validates SQL syntax. (3) Migrate test database from scratch in CI to verify full chain. (4) Deploy to staging first, run integration tests. (5) Deploy to production as a separate step. (6) Rollback script for every Migration. (7) Monitor flyway_schema_history for failures.

FAQ

Should I use Flyway or Liquibase?

Flyway if you prefer plain SQL and simplicity. Liquibase if you need cross-database support, rollback everywhere (not just paid), or XML/YAML changelogs.

How do I handle seed data with version control?

Use repeatable migrations or Liquibase loadData. Seed data (lookup tables, reference data) must be versioned alongside schema changes.

Can I use version control for NoSQL databases?

MongoDB does not enforce schema, but collections and indexes should be versioned. Use Migration tools like migrate-mongo or custom scripts stored in version control.

What is the best practice for Migration filenames?

V<version>__<description>.sql with leading zeros for sorting: V001__create_users.sql, V002__add_orders.sql. Descriptions should be short and meaningful.

Try It Yourself

Set up Flyway for a project:

  1. Install Flyway or use the Docker image
  2. Create V1__create_users.sql with users table
  3. Run flyway migrate and verify the table was created
  4. Create V2__add_orders.sql and apply it
  5. Check flyway_schema_history table
  6. Add a rollback script for V2
  7. Run flyway migrate and verify version tracking

What's Next

Database Migrations Guide
Backup and Recovery
Database Comparison Guide

You have learned database version control with Flyway and Liquibase, CI/CD integration, rollback strategies, and merge Conflict Resolution. Start by adding the Flyway dependency to your project and converting your current schema into V1.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro