Spring Boot
Liquibase Migration
Liquibase is a database migration tool that expresses schema changes as changesets in XML, YAML, JSON, or SQL. Spring Boot auto-configures Liquibase when it is on the classpath. Each changeset has an author and id, is tracked in the DATABASECHANGELOG table, and runs exactly once. This entry covers setup, changelog formats, changeset best practices, preconditions, rollback, contexts, and testing.
Setup and Configuration
Add the Liquibase dependency and Spring Boot auto-configures it. Liquibase reads the master changelog from classpath:db/changelog/db.changelog-master.yaml by default and runs all pending changesets on startup. Set spring.jpa.hibernate.ddl-auto=validate so Hibernate validates the schema Liquibase created.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
# āā application.yml āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
spring:
liquibase:
enabled: true
change-log: classpath:db/changelog/db.changelog-master.yaml
default-schema: public
drop-first: false # NEVER true in production
contexts: ${SPRING_PROFILES_ACTIVE:default}
label-filter: ${LIQUIBASE_LABELS:}
database-change-log-table: DATABASECHANGELOG
database-change-log-lock-table: DATABASECHANGELOGLOCK
jpa:
hibernate:
ddl-auto: validate
# āā application-test.yml āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
spring:
liquibase:
contexts: test
change-log: classpath:db/changelog/db.changelog-master.yaml
// āā Custom Liquibase configuration āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
@Configuration
public class LiquibaseConfig {
@Bean
public SpringLiquibase liquibase(DataSource dataSource) {
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(dataSource);
liquibase.setChangeLog(
"classpath:db/changelog/db.changelog-master.yaml");
liquibase.setContexts("production");
liquibase.setDefaultSchema("public");
liquibase.setShouldRun(true);
liquibase.setDropFirst(false);
return liquibase;
}
}Changelog Structure
The master changelog is the entry point ā it includes other changelog files using includeAll or include directives. Split changelogs by release, module, or feature. Liquibase supports XML, YAML, JSON, and SQL formats interchangeably. YAML is the most readable for complex changesets; SQL is useful for database-specific DDL.
yaml
# āā db/changelog/db.changelog-master.yaml āāāāāāāāāāāāāāāāāāāāāāāāāāāā
databaseChangeLog:
- includeAll:
path: db/changelog/migrations/
relativeToChangelogFile: false
# āā Or include individual files in order āāāāāāāāāāāāāāāāāāāāāāāāāāāāā
databaseChangeLog:
- include:
file: db/changelog/migrations/001-create-users.yaml
- include:
file: db/changelog/migrations/002-create-products.yaml
- include:
file: db/changelog/migrations/003-create-orders.yaml
- include:
file: db/changelog/migrations/004-add-audit-columns.yaml
# āā Directory structure āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
src/main/resources/
āāā db/
āāā changelog/
āāā db.changelog-master.yaml ā entry point
āāā migrations/
āāā 001-create-users.yaml
āāā 002-create-products.yaml
āāā 003-create-orders.yaml
āāā 004-add-audit-columns.yaml
āāā 005-create-indexes.yaml
āāā testdata/
āāā 006-seed-test-data.yaml
# āā XML master changelog (alternative format) āāāāāāāāāāāāāāāāāāāāāāāāā
<?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">
<includeAll path="db/changelog/migrations/"/>
</databaseChangeLog>YAML Changesets
Each changeset has a unique id and author combination. Changesets are atomic ā they run entirely or not at all. Use descriptive IDs that encode the date and purpose. Include a rollback section for every changeset that modifies or drops schema objects so automated rollback works reliably.
yaml
# āā 001-create-users.yaml āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
databaseChangeLog:
- changeSet:
id: 001-create-users-table
author: alice
comment: Create users table with email uniqueness constraint
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: email
type: VARCHAR(255)
constraints:
nullable: false
unique: true
uniqueConstraintName: uq_users_email
- column:
name: username
type: VARCHAR(50)
constraints:
nullable: false
unique: true
uniqueConstraintName: uq_users_username
- column:
name: status
type: VARCHAR(20)
defaultValue: ACTIVE
constraints:
nullable: false
- column:
name: created_at
type: TIMESTAMPTZ
defaultValueComputed: NOW()
constraints:
nullable: false
- column:
name: updated_at
type: TIMESTAMPTZ
defaultValueComputed: NOW()
constraints:
nullable: false
- column:
name: version
type: BIGINT
defaultValueNumeric: 0
constraints:
nullable: false
rollback:
- dropTable:
tableName: users
- changeSet:
id: 001-create-users-indexes
author: alice
changes:
- createIndex:
indexName: idx_users_email
tableName: users
columns:
- column:
name: email
- createIndex:
indexName: idx_users_created_at
tableName: users
columns:
- column:
name: created_at
descending: true
rollback:
- dropIndex:
indexName: idx_users_email
tableName: users
- dropIndex:
indexName: idx_users_created_at
tableName: usersSQL Changesets and Mixed Formats
SQL changesets wrap raw SQL in a changeset envelope. They are useful for database-specific DDL, stored procedures, complex views, and any SQL that cannot be expressed through Liquibase's built-in change types. Set splitStatements: false for multi-statement blocks like stored procedures.
yaml
# āā 003-create-orders.yaml ā mixed YAML and SQL āāāāāāāāāāāāāāāāāāāāāā
databaseChangeLog:
- changeSet:
id: 003-create-orders-table
author: bob
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: reference
type: VARCHAR(30)
constraints:
nullable: false
unique: true
uniqueConstraintName: uq_orders_reference
- column:
name: status
type: VARCHAR(20)
defaultValue: PENDING
constraints:
nullable: false
- column:
name: total
type: NUMERIC(10,2)
constraints:
nullable: false
- column:
name: placed_at
type: TIMESTAMPTZ
defaultValueComputed: NOW()
constraints:
nullable: false
- column:
name: shipped_at
type: TIMESTAMPTZ
rollback:
- dropTable:
tableName: orders
# āā Raw SQL changeset āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 003-create-order-summary-view
author: bob
changes:
- sql:
sql: |
CREATE OR REPLACE VIEW order_summary AS
SELECT
o.id AS order_id,
o.reference,
o.status,
o.total,
o.placed_at,
u.name AS customer_name,
u.email AS customer_email
FROM orders o
JOIN users u ON u.id = o.user_id;
splitStatements: false
rollback:
- sql:
sql: DROP VIEW IF EXISTS order_summary;
# āā Stored procedure via SQL āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 003-create-recalculate-function
author: bob
changes:
- sql:
sql: |
CREATE OR REPLACE FUNCTION recalculate_order_total(
p_order_id BIGINT
) RETURNS NUMERIC AS $$
DECLARE v_total NUMERIC(10,2);
BEGIN
SELECT COALESCE(SUM(line_total), 0)
INTO v_total
FROM order_items
WHERE order_id = p_order_id;
UPDATE orders SET total = v_total
WHERE id = p_order_id;
RETURN v_total;
END;
$$ LANGUAGE plpgsql;
splitStatements: false
endDelimiter: ";"
rollback:
- sql:
sql: DROP FUNCTION IF EXISTS recalculate_order_total(BIGINT);Preconditions
Preconditions guard a changeset ā they check database state before running and control what happens on failure. Use them to skip a changeset if a table already exists (for safe migration of pre-Liquibase databases), to assert a table does not yet exist before creating it, or to check the database type.
yaml
# āā Preconditions on a single changeset āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
databaseChangeLog:
- changeSet:
id: 004-add-avatar-url-column
author: carol
preConditions:
- onFail: MARK_RAN # mark as run if precondition fails
onError: HALT # stop migration on error
not:
columnExists:
tableName: users
columnName: avatar_url
changes:
- addColumn:
tableName: users
columns:
- column:
name: avatar_url
type: VARCHAR(500)
rollback:
- dropColumn:
tableName: users
columnName: avatar_url
# āā Skip if table already exists (pre-Liquibase database) āāāāāāāāāāāā
- changeSet:
id: 001-create-legacy-table
author: carol
preConditions:
- onFail: MARK_RAN
not:
tableExists:
tableName: legacy_data
changes:
- createTable:
tableName: legacy_data
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
# āā Database-type precondition āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 005-postgres-specific-index
author: carol
preConditions:
- onFail: MARK_RAN
dbms:
type: postgresql
changes:
- sql:
sql: |
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_products_name_fts
ON products
USING gin(to_tsvector('english', name));
splitStatements: false
# āā Row count precondition āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 006-add-default-admin
author: carol
preConditions:
- onFail: MARK_RAN
sqlCheck:
expectedResult: 0
sql: SELECT COUNT(*) FROM users WHERE username = 'admin'
changes:
- insert:
tableName: users
columns:
- column:
name: name
value: Administrator
- column:
name: email
value: admin@example.com
- column:
name: username
value: admin
- column:
name: status
value: ACTIVEContexts and Labels
Contexts tag changesets for specific environments ā dev, test, production. Only changesets matching the active context run. Labels provide finer-grained control for feature flags and release targeting. Contexts use OR logic by default; use AND and NOT for compound expressions.
yaml
# āā Changesets with contexts āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
databaseChangeLog:
# āā Runs in ALL environments (no context specified) āāāāāāāāāāāāāāāāāāā
- changeSet:
id: 001-create-users-table
author: alice
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
# āā Runs in dev and test only āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 002-seed-test-users
author: alice
context: "dev or test"
changes:
- insert:
tableName: users
columns:
- column: {name: name, value: "Test User"}
- column: {name: email, value: "test@example.com"}
- column: {name: username, value: "testuser"}
- column: {name: status, value: "ACTIVE"}
# āā Runs in production only āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 003-create-production-indexes
author: bob
context: production
changes:
- sql:
sql: |
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_orders_status_placed_at
ON orders (status, placed_at DESC);
splitStatements: false
# āā Labels ā feature flag āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 004-add-recommendations-table
author: carol
labels: "feature-recommendations,release-2.0"
changes:
- createTable:
tableName: recommendations
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
- column:
name: user_id
type: BIGINT
constraints:
nullable: false
- column:
name: product_id
type: BIGINT
constraints:
nullable: false
- column:
name: score
type: FLOAT
constraints:
nullable: false
# āā Active context configuration āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# application-production.yml:
# spring:
# liquibase:
# contexts: production
# application-test.yml:
# spring:
# liquibase:
# contexts: test
# Enable a label:
# spring:
# liquibase:
# label-filter: "feature-recommendations"Rollback
Liquibase supports automatic rollback for most built-in change types ā createTable rolls back with dropTable, addColumn with dropColumn, and so on. For SQL changesets and complex changes, always write an explicit rollback block. Test rollback scripts in a staging environment before relying on them in production.
yaml
# āā Explicit rollback sections āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
databaseChangeLog:
- changeSet:
id: 005-add-product-columns
author: dave
changes:
- addColumn:
tableName: products
columns:
- column:
name: weight_kg
type: NUMERIC(8,3)
- column:
name: dimensions
type: VARCHAR(50)
- column:
name: brand
type: VARCHAR(100)
rollback:
- dropColumn:
tableName: products
columnName: weight_kg
- dropColumn:
tableName: products
columnName: dimensions
- dropColumn:
tableName: products
columnName: brand
# āā Rollback for a rename āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 006-rename-product-column
author: dave
changes:
- renameColumn:
tableName: products
oldColumnName: product_code
newColumnName: sku
columnDataType: VARCHAR(50)
rollback:
- renameColumn:
tableName: products
oldColumnName: sku
newColumnName: product_code
columnDataType: VARCHAR(50)
# āā Rollback for a SQL data migration āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
- changeSet:
id: 007-normalise-email-addresses
author: dave
changes:
- sql:
sql: UPDATE users SET email = LOWER(TRIM(email));
rollback:
# Data rollback is often impossible ā mark as empty
- empty: {}
# āā Changeset with no automatic rollback ā must be explicit āāāāāāāāāā
- changeSet:
id: 008-drop-legacy-table
author: dave
changes:
- dropTable:
tableName: legacy_imports
rollback:
- createTable:
tableName: legacy_imports
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
- column:
name: data
type: TEXTTesting with Liquibase
Test Liquibase migrations with Spring Boot's test slice or with Testcontainers. Verify that all changesets apply cleanly, that the schema matches entity expectations, and that rollback scripts work correctly. Use separate test contexts to run seed data changesets in tests without affecting production migrations.
Java
// āā Integration test ā verify migrations apply cleanly āāāāāāāāāāāāāāā
@SpringBootTest
@ActiveProfiles("test")
class LiquibaseMigrationTest {
@Autowired
private DataSource dataSource;
@Autowired
private SpringLiquibase liquibase;
@Test
void allChangesetsApplyCleanly() throws Exception {
try (Connection conn = dataSource.getConnection()) {
DatabaseConnection dbConn =
new JdbcConnection(conn);
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(dbConn);
Liquibase lb = new Liquibase(
liquibase.getChangeLog(),
new ClassLoaderResourceAccessor(),
database);
List<ChangeSet> pending = lb.listUnrunChangeSets(
new Contexts(liquibase.getContexts()),
new LabelExpression());
assertThat(pending)
.as("No pending changesets should remain after startup")
.isEmpty();
}
}
@Test
void usersTableHasExpectedColumns(
@Autowired JdbcTemplate jdbcTemplate) {
List<Map<String, Object>> columns = jdbcTemplate.queryForList(
"SELECT column_name FROM information_schema.columns " +
"WHERE table_name = 'users' " +
"ORDER BY ordinal_position");
List<String> columnNames = columns.stream()
.map(c -> (String) c.get("column_name"))
.toList();
assertThat(columnNames).containsExactlyInAnyOrder(
"id", "name", "email", "username",
"status", "created_at", "updated_at", "version");
}
}
// āā Testcontainers ā test against real PostgreSQL āāāāāāāāāāāāāāāāāāāāā
@SpringBootTest
@Testcontainers
class LiquibasePostgresTest {
@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
private JdbcTemplate jdbcTemplate;
@Test
void canPersistAndRetrieveUser() {
jdbcTemplate.update(
"INSERT INTO users (name, email, username) " +
"VALUES (?, ?, ?)",
"Alice", "alice@example.com", "alice");
Map<String, Object> user = jdbcTemplate.queryForMap(
"SELECT * FROM users WHERE email = ?",
"alice@example.com");
assertThat(user.get("name")).isEqualTo("Alice");
assertThat(user.get("status")).isEqualTo("ACTIVE");
assertThat(user.get("version")).isEqualTo(0L);
}
@Test
void rollbackWorksCorrectly() throws Exception {
try (Connection conn =
Objects.requireNonNull(
jdbcTemplate.getDataSource()).getConnection()) {
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(
new JdbcConnection(conn));
Liquibase lb = new Liquibase(
"db/changelog/db.changelog-master.yaml",
new ClassLoaderResourceAccessor(),
database);
// Roll back last changeset
lb.rollback(1, new Contexts("test"),
new LabelExpression());
// Verify rollback took effect (last table dropped)
assertThatThrownBy(() ->
jdbcTemplate.queryForList("SELECT 1 FROM recommendations"))
.isInstanceOf(Exception.class);
}
}
}