Here is the translated article body:


How do you handle version control for your DB schema? With Spring Boot, Flyway is the natural pick since it’s supported out of the box, but there are also times when you want to reach for Liquibase for its rollback support and flexible syntax.

In this article, we’ll walk through integrating Liquibase into Spring Boot — from writing changelogs to executing rollbacks. We’ll also lay out the differences from Flyway so you have a basis for choosing between them.

Liquibase vs. Flyway

First, let’s compare the two to help with the adoption decision.

AspectLiquibaseFlyway
SyntaxXML/YAML/JSON/SQLSQL (OSS) / Java
RollbackNative supportUndo is Teams/Enterprise only (OSS handles it manually via SQL)
Learning curveA bit steepLow
DB supportVery broadMajor DBs

If you want to keep everything in SQL, Flyway fits well; if you want to abstract rollbacks and complex changes, Liquibase is a better match. For more on Flyway, see Managing Database Migrations with Flyway in Spring Boot.

Adding the Dependency and Initial Setup

With Maven, just add liquibase-core.

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>

On the application.yml side, specify the changelog path and context.

spring:
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/db.changelog-master.xml
    contexts: dev

If change-log is not specified, Spring Boot auto-detects classpath:/db/changelog/db.changelog-master.{yaml,yml,json,xml,sql}. When using XML, it’s reassuring to specify it explicitly.

Auto-migration is the work of LiquibaseAutoConfiguration. At application startup, Liquibase creates the DATABASECHANGELOG and DATABASECHANGELOGLOCK tables and runs any unapplied changeSets in order. To disable it, set spring.liquibase.enabled=false.

master changelog and Splitting Files

A bloated changelog quickly becomes hard to read, so split it by release or by feature. The standard layout is to have the master pull them in via include or includeAll. Match the XSD to your Liquibase version, or specify dbchangelog-latest.xsd to make it easier to keep up.

<?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">

    <include file="db/changelog/v1.0/001-create-users.xml"/>
    <include file="db/changelog/v1.0/002-create-orders.xml"/>
    <includeAll path="db/changelog/v1.1/"/>
</databaseChangeLog>

includeAll loads files in alphabetical order, so control ordering with prefixes like 001-, 002-.

Basic XML changeSet Syntax

A changeSet is uniquely identified by the combination of id and author. The cardinal rule is to never change the contents of a changeSet that has already been applied.

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

Foreign keys require the referenced table to already exist, so put them in a separate changeSet. This assumes the orders table is created first.

<changeSet id="002-create-orders" author="mizucoffee">
    <createTable tableName="orders">
        <column name="id" type="BIGINT" autoIncrement="true">
            <constraints primaryKey="true" nullable="false"/>
        </column>
        <column name="user_id" type="BIGINT">
            <constraints nullable="false"/>
        </column>
    </createTable>
</changeSet>

<changeSet id="003-fk-orders-users" author="mizucoffee">
    <addForeignKeyConstraint
        baseTableName="orders" baseColumnNames="user_id"
        referencedTableName="users" referencedColumnNames="id"
        constraintName="fk_orders_user"/>
</changeSet>

The constraints element lets you specify nullable, unique, and primaryKey. For entity design, JPA Entity Relationship Mapping is also a useful reference.

YAML and SQL Formats

If XML feels too verbose, you can write changelogs in YAML.

databaseChangeLog:
  - changeSet:
      id: 001-create-users
      author: mizucoffee
      changes:
        - createTable:
            tableName: users
            columns:
              - column:
                  name: id
                  type: BIGINT
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: email
                  type: VARCHAR(255)
                  constraints:
                    nullable: false
                    unique: true

If you already have SQL on hand, or your team is more comfortable with SQL, formatted SQL is also convenient.

--liquibase formatted sql

--changeset mizucoffee:001-create-users
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
--rollback DROP TABLE users;

Don’t mix formats — sticking to one across the project makes reviews much easier.

Conditional Execution with preConditions and context

When you want to toggle application based on environment differences, use preConditions and context.

<changeSet id="010-seed-dev-data" author="mizucoffee" context="dev">
    <preConditions onFail="MARK_RAN">
        <tableExists tableName="users"/>
        <sqlCheck expectedResult="0">SELECT COUNT(*) FROM users</sqlCheck>
    </preConditions>
    <insert tableName="users">
        <column name="email" value="[email protected]"/>
    </insert>
</changeSet>

context="dev" is matched against contexts in application.yml, so it runs only in the development environment. Note that if you start without specifying contexts, the filter is disabled and every changeSet becomes a target.

The distinction is that contexts answers “which environment to run in,” while labels answers “which tagged group of changes to run.” Use contexts for environment separation, and labels when you want to bundle changes like feature flags — splitting them this way keeps things tidy.

Writing Rollbacks

Liquibase’s biggest appeal is rollback support. Reversible changes like createTable get their reverse operation derived automatically, but for data changes or sql tags you write it manually.

<changeSet id="020-add-status-column" author="mizucoffee">
    <addColumn tableName="users">
        <column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"/>
    </addColumn>
    <rollback>
        <dropColumn tableName="users" columnName="status"/>
    </rollback>
</changeSet>

<changeSet id="021-tag-v1" author="mizucoffee">
    <tagDatabase tag="v1.0.0"/>
</changeSet>

If you set checkpoints with tagDatabase, you can later roll back to that tag. Here’s an example of running it via the Maven plugin.

# Roll back the most recent change
mvn liquibase:rollback -Dliquibase.rollbackCount=1

# Roll back to a specific tag
mvn liquibase:rollback -Dliquibase.rollbackTag=v1.0.0

For destructive DROP-style operations (the state after dropTable or delete), even with a rollback tag the data itself can’t be restored. Remember: the schema can be reverted, but the contents cannot. If you anticipate rollbacks, consider logical deletes or moving data to a separate table.

Per-Profile Operation

The simplest way to control behavior per environment is to switch via application-{profile}.yml.

# application-prod.yml
spring:
  liquibase:
    enabled: false  # In production, migrations are run explicitly from CI
    contexts: prod

When enabled: false is set for production, run it explicitly from CI/CD like this.

mvn liquibase:update -Dliquibase.contexts=prod

For more on switching profiles, see Safely Switching Environment-Specific Configuration with Profiles.

Common Pitfalls

Here’s a quick summary of points that tend to trip people up right after adoption.

  • Checksum mismatch error: Occurs when you edit an already-applied changeSet. As a rule, don’t edit them — add a new corrective changeSet instead. If you absolutely must, run mvn liquibase:clearCheckSums to recompute them.
  • DATABASECHANGELOGLOCK won’t release: If a lock is left behind by a forced shutdown or similar, release it with mvn liquibase:releaseLocks or UPDATE the table directly.
  • changelog not loaded: Check the classpath: path and file placement (src/main/resources/db/changelog/). Also confirm that includeAll isn’t pointing at an empty directory.
  • Coexisting with Hibernate ddl-auto: Combining it with update and similar settings can cause unexpected schema changes, so in production it’s safest to set it to none and consolidate on Liquibase.

Summary

Liquibase’s strengths are its flexible syntax across XML/YAML/SQL and its standard rollback support. Choose between Flyway and Liquibase based on your team’s skill set and the complexity of your changes.

Start small with a single changeSet, and secure release-level rollback points with tagDatabase — operations will get significantly easier.