Spring BootLiquibase Migration
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: users

SQL 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: ACTIVE

Contexts 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: TEXT

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