Liquibase: Changelogs, Rollbacks & Spring Boot Integration

Liquibase tracks database changes as a sequence of changeSets in a master changelog file. Unlike Flyway's plain SQL scripts, Liquibase supports both database-agnostic XML/YAML/JSON change types and raw SQL, and critically, it supports rollback scripts that can undo applied changes. Spring Boot auto-configures Liquibase on startup with sensible defaults.

Contents

Dependency & Auto-Configuration

Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.

XML
<dependency>
  <groupId>org.liquibase</groupId>
  <artifactId>liquibase-core</artifactId>
</dependency>
YAML
# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: app
    password: secret
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/db.changelog-master.xml
    default-schema: public

Spring Boot runs all pending changeSets on startup. The history is recorded in the DATABASECHANGELOG table, and locks are managed via DATABASECHANGELOGLOCK.

Master Changelog & Structure

The master changelog includes individual changelog files, keeping changes organised by feature or release.

XML
<!-- db/changelog/db.changelog-master.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-4.20.xsd">

  <include file="db/changelog/changes/001-create-users.xml"/>
  <include file="db/changelog/changes/002-create-orders.xml"/>
  <include file="db/changelog/changes/003-add-indexes.xml"/>
  <include file="db/changelog/changes/004-seed-data.xml"/>

</databaseChangeLog>
Text
src/main/resources/db/changelog/
├── db.changelog-master.xml
└── changes/
    ├── 001-create-users.xml
    ├── 002-create-orders.xml
    ├── 003-add-indexes.xml
    └── 004-seed-data.xml

ChangeSets: XML & YAML

Add the following XML configuration to your project. Each element is explained in the inline comments.

XML
<!-- changes/001-create-users.xml -->
<databaseChangeLog ...>

  <changeSet id="001" author="dev-team">
    <createTable tableName="users">
      <column name="id"         type="BIGINT" autoIncrement="true">
        <constraints primaryKey="true" nullable="false"/>
      </column>
      <column name="username"   type="VARCHAR(50)">
        <constraints nullable="false" unique="true"/>
      </column>
      <column name="email"      type="VARCHAR(255)">
        <constraints nullable="false" unique="true"/>
      </column>
      <column name="created_at" type="TIMESTAMP WITH TIME ZONE"
              defaultValueComputed="NOW()"/>
    </createTable>
  </changeSet>

  <changeSet id="002" author="dev-team">
    <addColumn tableName="users">
      <column name="display_name" type="VARCHAR(100)"/>
    </addColumn>
    <rollback>
      <dropColumn tableName="users" columnName="display_name"/>
    </rollback>
  </changeSet>

</databaseChangeLog>
YAML
# changes/002-create-orders.yaml — YAML format
databaseChangeLog:
  - changeSet:
      id: "003"
      author: dev-team
      changes:
        - createTable:
            tableName: orders
            columns:
              - column:
                  name: id
                  type: BIGINT
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: user_id
                  type: BIGINT
                  constraints:
                    nullable: false
                    foreignKeyName: fk_orders_user
                    references: users(id)
              - column:
                  name: status
                  type: VARCHAR(20)
                  defaultValue: PENDING
              - column:
                  name: total
                  type: DECIMAL(12,2)

Raw SQL ChangeSets

Use sql or sqlFile change types when you need database-specific SQL that the abstract change types don't cover.

XML
<changeSet id="004" author="dev-team" dbms="postgresql">
  <sql splitStatements="true" stripComments="true">
    CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);
    CREATE INDEX CONCURRENTLY idx_orders_status   ON orders (status);
  </sql>
  <rollback>
    <sql>
      DROP INDEX IF EXISTS idx_orders_user_id;
      DROP INDEX IF EXISTS idx_orders_status;
    </sql>
  </rollback>
</changeSet>

<!-- Reference an external SQL file -->
<changeSet id="005" author="dev-team">
  <sqlFile path="db/changelog/sql/create_order_summary_view.sql"
           relativeToChangelogFile="false"
           splitStatements="true"/>
</changeSet>

Rollback Support

Liquibase's key advantage over Flyway is first-class rollback. Many change types (createTable, addColumn) generate rollback SQL automatically; custom SQL changes require an explicit <rollback> block.

Shell
# Roll back the last 1 changeset
liquibase rollbackCount 1 \
  --url=jdbc:postgresql://localhost:5432/mydb \
  --username=app --password=secret \
  --changeLogFile=db/changelog/db.changelog-master.xml

# Roll back to a specific tag
liquibase rollback release-1.2 \
  --url=jdbc:postgresql://localhost:5432/mydb \
  --username=app --password=secret

# Roll back to a date
liquibase rollbackToDate 2024-01-15T10:00:00 \
  --url=jdbc:postgresql://localhost:5432/mydb

# Generate rollback SQL to review before applying
liquibase rollbackCountSQL 3 \
  --url=jdbc:postgresql://localhost:5432/mydb
Java
// Programmatic rollback from Spring
@Service
public class MigrationService {

    @Autowired
    private SpringLiquibase liquibase;

    public void rollbackToTag(String tag) throws LiquibaseException {
        try (var connection = liquibase.getDataSource().getConnection()) {
            Database database = DatabaseFactory.getInstance()
                .findCorrectDatabaseImplementation(new JdbcConnection(connection));
            Liquibase lb = new Liquibase(
                "db/changelog/db.changelog-master.xml",
                new ClassLoaderResourceAccessor(), database);
            lb.rollback(tag, "");
        }
    }
}

Preconditions

Preconditions guard a changeSet: if the condition is not met, the changeSet can be skipped (MARK_RAN), cause a warning, or fail the migration.

XML
<changeSet id="006" author="dev-team">
  <preConditions onFail="MARK_RAN">
    <!-- Only run if the column doesn't already exist -->
    <not>
      <columnExists tableName="users" columnName="phone"/>
    </not>
  </preConditions>
  <addColumn tableName="users">
    <column name="phone" type="VARCHAR(20)"/>
  </addColumn>
</changeSet>

<changeSet id="007" author="dev-team">
  <preConditions onFail="WARN">
    <!-- Require PostgreSQL in production -->
    <dbms type="postgresql"/>
  </preConditions>
  <sql>CREATE EXTENSION IF NOT EXISTS "uuid-ossp";</sql>
</changeSet>

Contexts & Labels

Contexts and labels let you run different changeSets in different environments: e.g., seed data only in development, performance indexes only in production.

XML
<!-- Only run in test and dev environments -->
<changeSet id="seed-001" author="dev-team" context="test,dev">
  <insert tableName="users">
    <column name="username" value="testuser"/>
    <column name="email"    value="testuser@example.com"/>
  </insert>
</changeSet>

<!-- Production-only: large table index (expensive, skip in dev) -->
<changeSet id="perf-001" author="dev-team" context="production">
  <sql>CREATE INDEX CONCURRENTLY idx_orders_created ON orders (created_at DESC);</sql>
</changeSet>
YAML
# application-dev.yml
spring:
  liquibase:
    contexts: dev

# application-prod.yml
spring:
  liquibase:
    contexts: production

Configuration Reference

The settings below can be placed in application.yml or application.properties. All keys shown are optional unless marked: Spring Boot applies sensible defaults when they are omitted.

YAML
spring:
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/db.changelog-master.xml
    default-schema: public
    liquibase-schema: public        # schema for DATABASECHANGELOG tables
    contexts: dev                   # comma-separated active contexts
    labels:                         # label expression to filter changeSets
    parameters:                     # ${param} substitution map
      tablePrefix: app_
    rollback-file:                  # generate rollback SQL here on startup
    test-rollback-on-update: false  # verify rollback on every migration (slow — dev only)
    drop-first: false               # DROP all before migrate — NEVER in production
    database-change-log-table: DATABASECHANGELOG
    database-change-log-lock-table: DATABASECHANGELOGLOCK
    lock-wait-time: 5               # minutes to wait for lock

Testing with Testcontainers

Testcontainers spins up a real database (or broker) in Docker for each test run, giving you integration-test confidence without a shared external service. The @DynamicPropertySource method wires the container's JDBC URL into Spring's environment before the application context starts.

Java
@SpringBootTest
@Testcontainers
class LiquibaseMigrationTest {

    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb").withUsername("test").withPassword("test");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.liquibase.contexts", () -> "test");
    }

    @Autowired JdbcTemplate jdbcTemplate;

    @Test
    void allChangeSetsApplied() {
        int count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM DATABASECHANGELOG WHERE exectype = 'EXECUTED'",
            Integer.class);
        assertThat(count).isGreaterThan(0);
    }

    @Test
    void usersTableExists() {
        assertDoesNotThrow(() ->
            jdbcTemplate.queryForList("SELECT 1 FROM users LIMIT 1"));
    }
}

Flyway vs Liquibase

FeatureFlywayLiquibase
Script formatSQL (primary), JavaXML, YAML, JSON, SQL
RollbackManual SQL (Flyway Teams)First-class, auto-generated for many types
DB-agnostic changesNo: raw SQL onlyYes: abstract types like createTable
PreconditionsNoYes
Context filteringNoYes: context & labels
Learning curveLow: just SQL filesHigher: changelog XML/YAML
Spring Boot supportAuto-configuredAuto-configured