Contents

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")); } }
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