Database Migration with Liquibase: Schema Versioning, Safe Rollbacks, and CI/CD Automation

Few things derail a production release quite like manual schema updates. We have all lived through that 11 PM Slack panic: someone skipped an ALTER TABLE script on staging, or an ill-timed column rename locked 10 million rows and crashed the checkout API.

Liquibase eliminates this gamble. It treats database schema changes like application code: versioned in Git, verified in pull requests, and deployed through automated pipelines. Relational databases like PostgreSQL and MySQL demand strict versioning before ad-hoc patches turn into an untangleable mess.

Quick Start: Your First Migration in 5 Minutes

You need only two small config files to get rolling. You do not need to overhaul your existing database to begin.

1. Grab the Liquibase CLI

Install the binary via Homebrew, package managers, or official archives:

# macOS
brew install liquibase

# Verify installation
liquibase --version

2. Configure Database Credentials

Create liquibase.properties in your project root. This tells Liquibase where your target database lives and which changelog to execute:

changeLogFile=changelog.xml
url=jdbc:postgresql://localhost:5432/appdb
username=postgres
password=secret
driver=org.postgresql.Driver
classpath=/path/to/postgresql-42.7.2.jar

3. Write Your First ChangeSet

Liquibase structures schema mutations into ChangeSets inside a root changelog file. Create changelog.xml:

<?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-latest.xsd">

    <changeSet id="001-create-users-table" author="alex">
        <createTable tableName="users">
            <column name="id" type="BIGINT" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="email" type="VARCHAR(255)">
                <constraints unique="true" nullable="false"/>
            </column>
            <column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
        </createTable>
        <rollback>
            <dropTable tableName="users"/>
        </rollback>
    </changeSet>

</databaseChangeLog>

4. Execute the Migration

Run the migration directly from your terminal:

liquibase update

Liquibase connects to PostgreSQL, creates the users table, and records the run in an audit ledger. Your schema is now code.

Under the Hood: How Liquibase Tracks Schema State

Run liquibase update once, and you will see two tracking tables appear in your schema automatically:

  • DATABASECHANGELOG: The audit history. Each applied ChangeSet gets recorded here with its unique identifier, execution timestamp, run order, and an MD5 checksum.
  • DATABASECHANGELOGLOCK: A concurrency gate. A single boolean flag (LOCKED) ensures only one runner applies migrations at a time. This prevents race conditions when four Kubernetes replicas boot at once.

Identity and Checksums

Liquibase identifies every ChangeSet by three properties: id, author, and changelog file path. When you invoke an update:

  1. Liquibase queries DATABASECHANGELOG to check if the (id, author, filepath) composite key exists.
  2. If found, it hashes your local ChangeSet file contents and compares the MD5 hash against the table entry. If the hashes match, Liquibase skips execution.
  3. If someone modified an already-applied ChangeSet, Liquibase aborts immediately with a ValidationFailedException. This blocks silent configuration drift before broken SQL hits production.

ChangeSet Formats: XML, YAML, or Formatted SQL?

XML and YAML provide database portability, generating appropriate dialects across PostgreSQL, MySQL, and Oracle behind the scenes. However, engineering teams often prefer formatted SQL for clarity during code reviews:

--liquibase formatted sql

--changeset alex:002-add-user-status
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'ACTIVE';
--rollback ALTER TABLE users DROP COLUMN status;

Formatted SQL gives your team exact control over database-specific keywords. Use XML or YAML when building multi-tenant SaaS products that target several database vendors simultaneously.

Defensive Patterns: Preconditions, Contexts, and Rollbacks

Production environments introduce messy data migrations, test seeds, and unexpected state drift.

Preconditions

Preconditions validate database state before a ChangeSet runs. If an index already exists or an old column is missing, you can skip cleanly rather than failing the deploy:

<changeSet id="003-add-profile-bio" author="alex">
    <preConditions onFail="MARK_RAN">
        <not>
            <columnExists tableName="profiles" columnName="bio"/>
        </not>
    </preConditions>
    <addColumn tableName="profiles">
        <column name="bio" type="TEXT"/>
    </addColumn>
</changeSet>

Targeting Environments with Contexts

You might need 50 mock customer records in local staging, but never in production. Contexts partition your migrations:

<changeSet id="seed-dev-users" author="alex" context="dev,test">
    <insert tableName="users">
        <column name="email" value="[email protected]"/>
    </insert>
</changeSet>

Run targeted executions by passing the matching context flag:

liquibase --contexts=dev update

Executing Safe Rollbacks

When an update triggers unexpected locks or errors, Liquibase can roll back by count, tag, or target timestamp:

# Roll back the most recent ChangeSet
liquibase rollbackCount 1

# Tag the current state before deploying, then revert if canary checks fail
liquibase tag v2.4.0
liquibase rollback v2.4.0

Never execute blind rollbacks in production. Preview the exact SQL Liquibase will issue first:

liquibase rollbackCountSQL 1 > rollback_preview.sql

CI/CD Automation & Production Best Practices

Running migrations from local laptops risks version discrepancies and credential leaks. Embedding Liquibase into CI/CD makes database upgrades automated and auditable.

GitHub Actions Workflow

The following workflow checks pending ChangeSets and runs migrations on push to main:

name: Database Migration

on:
  push:
    branches: [ main ]
    paths:
      - 'db/changelogs/**'

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Liquibase
        uses: liquibase-github-actions/setup-liquibase@v1
        with:
          liquibaseVersion: '4.27.0'

      - name: Inspect Pending Changes
        run: |
          liquibase \
            --changeLogFile=db/changelogs/master.xml \
            --url="${{ secrets.STAGING_DB_URL }}" \
            --username="${{ secrets.STAGING_DB_USER }}" \
            --password="${{ secrets.STAGING_DB_PASS }}" \
            status --verbose

      - name: Run Update
        run: |
          liquibase \
            --changeLogFile=db/changelogs/master.xml \
            --url="${{ secrets.STAGING_DB_URL }}" \
            --username="${{ secrets.STAGING_DB_USER }}" \
            --password="${{ secrets.STAGING_DB_PASS }}" \
            update

Field-Tested Rules for Zero-Downtime Releases

  • Never edit an applied ChangeSet: Once a ChangeSet executes in staging or production, its checksum is locked. If you need to expand a column from VARCHAR(50) to VARCHAR(100), create a new ChangeSet.
  • Adopt the Expand-Contract pattern: Avoid dropping or renaming active columns in a single release. First, add the new column and deploy code that dual-writes to both fields. Backfill old rows asynchronously. Then, switch your read queries to the new column. Finally, drop the deprecated column in a follow-up release.
  • Structure changelogs modularly: Avoid 5,000-line changelogs. Group migrations by release (e.g., v1.2/01-orders.xml) and assemble them using <include file="..."/> in a parent master.xml.
  • Write explicit rollbacks: Automatic reverse migrations only work for simple DDL statements like createTable. Complex SQL, table renames, and data backfills require hand-crafted <rollback> blocks.
  • Prevent table locks on large tables: Running CREATE INDEX on a table with 20 million rows can lock writes for 15 minutes. Use non-blocking commands like PostgreSQL’s CREATE INDEX CONCURRENTLY or MySQL’s ALGORITHM=INPLACE within formatted SQL ChangeSets.
Share: