Contents
- Dependency & Auto-Configuration
- Master Changelog & Structure
- ChangeSets — XML & YAML
- Raw SQL ChangeSets
- Rollback Support
- Preconditions
- Contexts & Labels
- Configuration Reference
- Testing with Testcontainers
- Flyway vs Liquibase
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.
<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 DATABASECHANGELOG table, and locks are managed via DATABASECHANGELOGLOCK.
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
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)
Use sql or sqlFile change types when you need database-specific SQL that the abstract change types don't cover.
<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>
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.
# 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 guard a changeSet — if the condition is not met, the changeSet can be skipped (MARK_RAN), cause a warning, or fail the migration.
<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 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
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.
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
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.
@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"));
}
}
| 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 — context & labels |
| Learning curve | Low — just SQL files | Higher — changelog XML/YAML |
| Spring Boot support | Auto-configured | Auto-configured |