Spring Boot – Schema Migrations with Liquibase

July 15, 20265 min readUpdated 8/18/2026

Your application code is in version control. Your database schema, in most projects, is in somebody's head and a folder of SQL files with names like fix_final_v2.sql. Liquibase puts the schema under the same discipline as the code: every change is a file, applied in order, recorded, identical on every machine.

Setup

<!-- Spring Boot 4 modularized autoconfiguration. Depending on plain
     liquibase-core (all that Boot 3 needed) gives you the library with NO
     autoconfiguration: Liquibase silently never runs and the failure shows up
     as a confusing Hibernate "Schema validation: missing table" error. -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-liquibase</artifactId>
</dependency>

⚠️ Read that comment twice if you are coming from Boot 3. liquibase-core was enough there; in Boot 4 it gives you the library and no autoconfiguration, so migrations never run — and the error you eventually see comes from Hibernate, about a missing table, several layers from the cause.

spring.liquibase.enabled=true
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml

# Liquibase owns the schema, so Hibernate must not touch it — only check it.
spring.jpa.hibernate.ddl-auto=validate

validate is the other half of the setup. Liquibase creates the schema; Hibernate compares it against the entities and refuses to start if they have drifted. Two tools, each doing one job, and a loud failure at boot rather than a silent mismatch found in production.

The master changelog is a manifest

<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">

    <!--
      This file is a MANIFEST ONLY. Every actual change lives in a formatted-SQL
      file under sql/.

      Why is the master XML when the changesets are SQL? Liquibase's formatted-SQL
      format has no include/includeAll directive, so a .sql master cannot pull in
      other files. The manifest has to be XML (or YAML/JSON); the changes stay SQL.

      includeAll orders files by filename, which is why they are numerically prefixed.
    -->
    <includeAll path="db/changelog/sql" relativeToChangelogFile="false"/>

</databaseChangeLog>

That comment answers the question everyone asks — why mix XML and SQL — and the answer is a real constraint, not a preference.

Liquibase can express changes in XML, YAML or JSON, which makes them portable across database vendors. The pizza API writes plain SQL instead, on the grounds that a team fluent in SQL reads it faster than an abstraction over it, and that a demo targeting one database gains nothing from portability. Both positions are defensible; pick one.

A changeset

--liquibase formatted sql

-- Adds a `deleted` flag to every table, matching the trademachine convention.
--
-- `deleted` and `active` are NOT the same thing, and both are kept:
--   * active  = temporarily off the menu. Still visible and editable in the admin screen.
--   * deleted = gone for good. Filtered out of every query by @SQLRestriction on the entity.
--
-- Rows are never physically removed, because historical orders reference them.

--changeset pizza:500-add-deleted-flag
ALTER TABLE product            ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE product_size       ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE customer_order     ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE;
--rollback ALTER TABLE product DROP COLUMN deleted;
--rollback ALTER TABLE product_size DROP COLUMN deleted;
--rollback ALTER TABLE customer_order DROP COLUMN deleted;

Four things to notice:

  • --liquibase formatted sql must be the first line. Without it the file is ignored — silently.
  • --changeset author:id identifies each unit. The id must be unique within the file and never change.
  • --rollback tells Liquibase how to undo it. Optional, and worth writing while you still remember.
  • The comment explains why. "Adds a deleted column" is visible in the SQL; that active and deleted mean different things is not.

⚠️ Never edit an applied changeset

This is the rule that produces the confusing failure. Liquibase records a checksum of every changeset it applies, in a table called DATABASECHANGELOG. On the next startup it re-reads your files and compares. Change one character in an applied changeset and:

liquibase.exception.ValidationFailedException: Validation Failed:
     1 changesets check sum
          db/changelog/sql/006-add-soft-delete.sql::500-add-deleted-flag::pizza
          was: 8:a1b2c3d4e5f6... but is now: 8:f6e5d4c3b2a1...

That is Liquibase working correctly. The database has already run the old version; your file now says something different; it cannot know which is true. Add a new changeset instead. Even for a typo in a comment.

The escape hatch — clearCheckSums, or editing the DATABASECHANGELOG table — exists, is occasionally necessary on a development database, and should never be reached for on production. Fixing forward is almost always cheaper.

Two tables Liquibase creates

TableWhat it does
DATABASECHANGELOGevery applied changeset, with its checksum and when it ran
DATABASECHANGELOGLOCKa mutex, so two instances starting together do not both migrate

The lock is why Liquibase is safe with multiple replicas — the first one in migrates, the others wait. It is also the cause of a hang after a crash: a killed process leaves the lock held, and the next startup waits forever. liquibase releaseLocks, or delete the row.

Ordering and naming

db/changelog/
├── db.changelog-master.xml
└── sql/
    ├── 001-schema.sql
    ├── 002-seed-menu.sql
    ├── 003-seed-orders.sql
    ├── 004-add-public-ids.sql
    ├── 005-add-audit-timestamps.sql
    ├── 006-add-soft-delete.sql
    ├── 007-cart.sql
    ├── 008-user-addresses-and-payment-methods.sql
    └── 009-order-card-details.sql

includeAll orders by filename, which is why they are numerically prefixed. Zero-pad the numbers — without padding, 10-x.sql sorts before 2-x.sql and your migrations run in the wrong order.

The names are also a readable history of the project. Reading that list top to bottom tells you the schema started simple, gained public UUIDs, then audit timestamps, then soft deletes, then a cart.

Seed data

002-seed-menu.sql and 003-seed-orders.sql are migrations too. That is convenient — every developer gets the same fourteen products — and it has a consequence worth knowing: the pizza API's tests assert absolute seeded counts, so changing a seed changes what the tests expect.

For production, keep reference data (countries, statuses) in migrations and keep demo data in a separate changelog activated by a profile.

Useful commands

# What would run, without running it
./mvnw liquibase:updateSQL

# Apply pending changesets (Boot also does this at startup)
./mvnw liquibase:update

# Undo the last N
./mvnw liquibase:rollback -Dliquibase.rollbackCount=1

# Generate a changelog from an existing database — how you adopt Liquibase mid-project
./mvnw liquibase:generateChangeLog

updateSQL is the one to build a habit around: it prints the SQL that would run, which is what you want to read before a production deploy.

Liquibase or Flyway?

Both are good and Boot supports both. Flyway is simpler — numbered SQL files, no manifest, no abstraction layer. Liquibase does more: rollbacks, preconditions, contexts, and vendor-neutral changesets if you want them. If you only ever write SQL against one database, Flyway is less machinery for the same result.

What to take from this

  • Boot 4 needs spring-boot-starter-liquibase, not liquibase-core.
  • Liquibase owns the schema, ddl-auto=validate polices it.
  • Never edit an applied changeset. Add a new one.
  • Zero-pad the filenames and write a comment saying why, not what.
  • updateSQL before a production deploy.

Next: mapping DTOs with MapStruct — and the annotation-processor ordering that silently produces mappers that map nothing.