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.xmlbuild.gradle
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
# 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 DATABASECHANGELOGDATABASECHANGELOGLOCK
Master Changelog & Structure
The master changelog includes individual changelog files, keeping changes organised by feature or release.
<!-- 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>
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.
<!-- 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>
# 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 sqlsqlFile
<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>
# 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
// 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
<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.
<!-- 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>
# application-dev.yml
spring:
liquibase:
contexts: dev
# application-prod.yml
spring:
liquibase:
contexts: production
Configuration Reference
The settings below can be placed in application.ymlapplication.properties
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
@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
| Feature | Flyway | Liquibase |
|---|---|---|
| Script format | SQL (primary), Java | XML, YAML, JSON, SQL |
| Rollback | Manual SQL (Flyway Teams) | First-class, auto-generated for many types |
| DB-agnostic changes | No: raw SQL only | Yes: abstract types like createTable |
| Preconditions | No | Yes |
| Context filtering | No | Yes: contextlabels |
| Learning curve | Low: just SQL files | Higher: changelog XML/YAML |
| Spring Boot support | Auto-configured | Auto-configured |