Your entities drift, someone adds a column with ddl-auto: update, and three environments end up with three slightly different schemas nobody can reconstruct. The schema is the most important state your application has, and it deserves the same version control as your code. This tutorial puts a Postgres schema under Liquibase, keeps Hibernate on validate, and proves every step with a Testcontainers integration test.
All the code is in the companion repo: github.com/umur/spring-boot-liquibase. Clone it, run mvn verify, and watch four integration tests take a real Postgres from an empty schema to a rolled-back release, proving each step.
Why ddl-auto Is Not Version Control
Letting Hibernate manage the schema with ddl-auto: update feels convenient until it is the thing paging you. It only ever adds. It creates a missing table or column and stops there. A rename, a drop, a type change, a backfill: all of those are outside its reach, because it knows only what the entities look like right now, not what you meant. There is no record of what ran, no ordering, and no way to rebuild production's schema from scratch.
I wrote about that failure mode in Hibernate's ddl-auto Is Not a Migration Tool. The fix is to make schema changes explicit, ordered, and reversible. That is what a migration tool is for, and Liquibase is one of the two that matter on the JVM.
Wiring Liquibase into Spring Boot 4
Spring Boot 4 splits Liquibase into its own starter. One dependency pulls in both the integration and the engine:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-liquibase</artifactId>
</dependency>The configuration is two decisions. Point Liquibase at a master changelog, and set Hibernate to validate so it never touches the schema and only checks that your entities match what Liquibase built:
spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
jpa:
hibernate:
ddl-auto: validateThat pairing is the whole philosophy. Liquibase owns the schema. Hibernate owns the check that your mappings still fit it. If the two drift, the application refuses to start instead of limping along against a schema it no longer understands.
The Master Changelog and Your First ChangeSet
The master changelog is an ordered table of contents. Liquibase runs each included file once, top to bottom, and new work always arrives as a new file at the bottom. You never edit a changeSet that has already run.
databaseChangeLog:
- include:
file: db/changelog/changes/v1-create-customers.yaml
- include:
file: db/changelog/changes/v2-create-orders.yamlA changeSet is a single identified unit of change. The id plus author make it unique, and Liquibase fingerprints its contents, so an accidental edit later is caught instead of silently ignored. Here is the first one, written with native change types instead of raw SQL:
databaseChangeLog:
- changeSet:
id: 1-create-customers
author: umur
changes:
- createTable:
tableName: customers
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
- column:
name: email
type: varchar(255)
constraints:
nullable: false
unique: trueBecause createTable is a change type Liquibase understands, not a SQL string it merely forwards, it knows how to undo the change and how to render it for Postgres, MySQL, or anything else. That portability and that automatic rollback are exactly what you give up the moment you drop to raw SQL.
How Liquibase Tracks What It Ran
On first run Liquibase creates two bookkeeping tables. DATABASECHANGELOG holds one row per applied changeSet, with its id, author, checksum, and execution time. DATABASECHANGELOGLOCK is a single-row mutex that stops two instances from migrating at the same moment during a rolling deploy.
That tracking table is the source of truth for what has run. On every startup Liquibase compares the changelog on the classpath against those rows and applies only what is new. Re-running an already-applied migration is a no-op, which is why booting ten replicas against one database is safe. The companion repo asserts exactly this: after startup, the tracking table holds one row per executed changeSet.
Evolving the Schema Safely
Real schemas grow, and the hard part is growing them without breaking the rows already there. Adding a non-null column to a populated table fails unless existing rows get a value, so additive-with-a-default is the safe pattern:
- changeSet:
id: 3-add-order-status
author: umur
changes:
- addColumn:
tableName: orders
columns:
- column:
name: status
type: varchar(20)
defaultValue: PENDING
constraints:
nullable: falseEvery existing order becomes PENDING, the column is not null from here on, and nothing breaks. Each later change is its own appended changeSet: a new index, another column, a constraint. The schema's whole history then reads top to bottom like a commit log, because that is what it has become.
Rollback Comes for Free
Native change types know their own inverse, so Liquibase can roll them back with no hand-written down-migration. To make rollback a release-level operation, drop a tag at each release point:
- changeSet:
id: 6-tag-release-1.0
author: umur
changes:
- tagDatabase:
tag: v1.0Now suppose the next release renames a column, the kind of refactor ddl-auto can never express:
- changeSet:
id: 7-rename-customer-name
author: umur
changes:
- renameColumn:
tableName: customers
oldColumnName: name
newColumnName: full_name
columnDataType: varchar(255)Rolling back to the v1.0 tag undoes every changeSet applied after it, in reverse, which here renames full_name back to name. No down-script, no manual SQL. The companion repo runs that rollback through the Liquibase API against a live database and asserts the column actually reverted.
Contexts: Keeping Dev Data Out of Prod
You often want reference or sample data in dev and test but never in production. Contexts handle that, with one sharp edge worth knowing up front. Tag the changeSet with a context:
- changeSet:
id: 5-seed-reference-data
author: umur
context: dev
changes:
- insert:
tableName: customers
columns:
- column:
name: email
value: [email protected]
- column:
name: name
value: Ada LovelaceThe sharp edge: a run with no active context applies every changeSet, including the context-tagged ones. A context is a filter, and an empty filter matches everything. Production then has to name its context to keep dev data out:
spring:
liquibase:
contexts: ${LIQUIBASE_CONTEXTS:prod}Boot with contexts=prod and the seed changeSet is skipped; boot with contexts=dev and it runs. The repo proves both directions: the prod-context boot leaves customers empty, and the dev-context boot loads the two reference rows.
Testing Migrations with Testcontainers
Migrations deserve tests as much as application code does, and the only honest test runs against the real engine. An in-memory stand-in will happily accept SQL that Postgres rejects. Each integration test in the repo starts a Postgres container, boots the app so Liquibase migrates and Hibernate validates, then asserts the outcome:
@SpringBootTest
@Testcontainers
class SchemaMigrationIT {
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17-alpine");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}The context loads only if Liquibase built the schema and validate then accepted it, so a green context is the first assertion before a single line of test body runs. The rest check the tracking table, the renamed column, the index, the contexts, and the tagged rollback. That is the gap between believing your migrations work and knowing they do.
Quick Reference
| Concept | What it does |
|---|---|
ddl-auto: validate | Hibernate checks entities against the schema and never changes it |
| master changelog | Ordered list of changeSets; new work appended, old never edited |
DATABASECHANGELOG | One row per applied changeSet; the source of truth for what ran |
| native change types | createTable, addColumn, renameColumn: auto-rollback and DB-agnostic |
tagDatabase | A named point you can roll back to |
| contexts | Filter changeSets by environment; an empty filter runs them all |
YAML with native change types is the right default: readable, reversible, and portable. Reach for SQL formatted changeSets only when you need a database-specific feature Liquibase cannot model, and accept that you then own the rollback. For everything else, let the change types do the work, keep Hibernate on validate, and your schema finally gets the version control your code has had all along.