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=validatevalidate 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 sqlmust be the first line. Without it the file is ignored — silently.--changeset author:ididentifies each unit. The id must be unique within the file and never change.--rollbacktells 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
activeanddeletedmean 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
| Table | What it does |
|---|---|
DATABASECHANGELOG | every applied changeset, with its checksum and when it ran |
DATABASECHANGELOGLOCK | a 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.sqlincludeAll 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:generateChangeLogupdateSQL 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, notliquibase-core. - Liquibase owns the schema,
ddl-auto=validatepolices it. - Never edit an applied changeset. Add a new one.
- Zero-pad the filenames and write a comment saying why, not what.
updateSQLbefore a production deploy.
Next: mapping DTOs with MapStruct — and the annotation-processor ordering that silently produces mappers that map nothing.