Overview
Liquibase's distinguishing feature vs Flyway: changesets are format-portable (XML / YAML / JSON / SQL) and rollback-aware - each changeset declares its own rollback action, enabling deterministic per-changeset rollback rather than versioned-only migration replay.
State tracked in two tables (per Liquibase docs):
When to use
Step 1 - Install
Pick one runner and pin its version:
# CLI in a container (CI and local)
docker run --rm -v "$PWD/changelog:/liquibase/changelog" \
-e LIQUIBASE_COMMAND_PASSWORD \
liquibase/liquibase:5.0.4 \
--url=jdbc:postgresql://host/db --username=app \
--changelog-file=changelog/db.changelog-master.yaml \
status --verboseKeep the password off the command line: any command option can be set as LIQUIBASE_COMMAND_<option-name> (environment variables (opens in new window)), and -e LIQUIBASE_COMMAND_PASSWORD with no value forwards the host's variable into the container (docker run (opens in new window)).
Maven (Maven integration (opens in new window)):
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>5.0.4</version>
<configuration>
<changeLogFile>src/main/resources/db/changelog/db.changelog-master.yaml</changeLogFile>
<url>${env.DB_URL}</url>
<username>${env.DB_USER}</username>
<password>${env.DB_PASSWORD}</password>
</configuration>
</plugin>then mvn liquibase:validate liquibase:update. Spring Boot: with spring-boot-starter-liquibase on the classpath, migrations run at startup and before tests, reading db/changelog/db.changelog-master.yaml unless spring.liquibase.change-log says otherwise (Spring Boot (opens in new window)).
Step 2 - Changelog + changeset basics
A changelog is a file (or set of included files) containing one or more changeset entries. Each changeset has an id + author (these are the identity for the DATABASECHANGELOG row).
YAML example:
databaseChangeLog:
- changeSet:
id: 1
author: alice
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints: { primaryKey: true, nullable: false }
- column:
name: email
type: varchar(255)
constraints: { unique: true, nullable: false }
rollback:
- dropTable:
tableName: users
- changeSet:
id: 2
author: alice
changes:
- addColumn:
tableName: users
columns:
- column: { name: created_at, type: timestamp }
rollback:
- dropColumn: { tableName: users, columnName: created_at }Equivalent forms in XML / JSON / SQL exist - pick by team preference; they're functionally equivalent at apply time. (Per lb-docs (opens in new window) for format-specific syntax.)
Step 3 - Gate: validate and preview before update
Run these in order; stop at the first failure:
liquibase validate # 1. syntax, duplicate id/author/file, checksums
liquibase status --verbose # 2. which changesets are pending
liquibase update-sql > pending.sql # 3. read the SQL that update would run
liquibase update # 4. applyvalidate checks that the changelog parses, that no id / author / file combination repeats, and that no applied changeset's checksum changed (validate (opens in new window)). update-sql prints the SQL update would run so it can be reviewed; it does not check that SQL for correctness (update-sql (opens in new window)).
On a checksum error, someone edited a changeset that already ran. Revert the edit and put the change in a new changeset. Only if the edit is deliberately harmless (a comment, whitespace), add that changeset's new checksum to a validCheckSum entry and say why in the PR; never clear checksums to make the gate pass.
On a held lock after an interrupted run, confirm no other Liquibase process is running, then liquibase release-locks (release-locks (opens in new window)).
Other commands:
| Command | Use |
|---|---|
liquibase history | Show executed changesets |
liquibase tag <tag> / rollback <tag> | Mark a state; roll back to it |
liquibase rollback-count <n> | Roll back the last N changesets |
liquibase update-testing-rollback | Update, roll back the same changesets, update again: proves every rollback works (update-testing-rollback (opens in new window)) |
liquibase changelog-sync | Mark pending changesets as applied without running them (adopting an existing schema) |
Step 4 - Preconditions
Changesets can declare preconditions that must hold before they're applied (or the changeset is skipped / failed):
- changeSet:
id: 3
author: alice
preConditions:
- onFail: MARK_RAN # if precondition fails, mark as run without executing
- onError: MARK_RAN
- tableExists: { tableName: users }
changes:
- addColumn: { tableName: users, columns: [{ column: { name: status, type: varchar(20) }}] }Common precondition predicates: tableExists, columnExists, viewExists, indexExists, foreignKeyConstraintExists, changeSetExecuted, dbms (e.g., only-on-postgresql), primaryKeyExists, sqlCheck (custom SQL returning a single value).
onFail / onError actions: HALT (default - fails the run), CONTINUE, MARK_RAN, WARN.
Step 5 - Contexts and labels
Selective changeset execution per environment / feature:
- changeSet:
id: 4
author: alice
contexts: dev,test # only run when --contexts dev or test
labels: experimental # filterable by --labels selector
changes:
- createTable: { tableName: feature_x_audit, columns: [...] }Run contexts at apply time:
liquibase update --contexts="dev,test"
liquibase update --label-filter="!experimental" # exclude experimentalStep 6 - Include + includeAll for splitting changelogs
Master changelog references file groups (per docs.liquibase.com/concepts/changelogs (opens in new window)):
databaseChangeLog:
- include: { file: changelog/v1.0/users.yaml }
- include: { file: changelog/v1.0/orders.yaml }
- includeAll: { path: changelog/v2.0/, errorIfMissingOrEmpty: true }The includeAll orders files alphabetically; use prefixes (001-users.yaml, 002-orders.yaml) to enforce order.
Step 7 - CI integration
services:
postgres:
image: postgres:17
env: { POSTGRES_PASSWORD: ci }
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- name: Liquibase gate on a throwaway database
env:
LIQUIBASE_COMMAND_URL: jdbc:postgresql://localhost:5432/postgres
LIQUIBASE_COMMAND_USERNAME: postgres
LIQUIBASE_COMMAND_PASSWORD: ci
LIQUIBASE_COMMAND_CHANGELOG_FILE: changelog/db.changelog-master.yaml
run: |
lb() { docker run --rm --network=host -v "$PWD/changelog:/liquibase/changelog" \
-e LIQUIBASE_COMMAND_URL -e LIQUIBASE_COMMAND_USERNAME \
-e LIQUIBASE_COMMAND_PASSWORD -e LIQUIBASE_COMMAND_CHANGELOG_FILE \
liquibase/liquibase:5.0.4 "$@"; }
lb validate
lb update-sql
lb update-testing-rollbackThe job fails on the first failing command (bash -e is the default shell on GitHub-hosted runners), so a checksum error stops it before anything is applied. Run the same validate step in the deploy job before update against each real environment.
Step 8 - Review risky changesets
Run the migration-blast-radius-reviewer agent on new changesets and classify each with migration-operation-taxonomy (additive, breaking, data-loss, locking) before merge.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Reuse a changeset id after it's been deployed | Checksum mismatch on next update; deploys fail | New id per change; never edit deployed changesets |
| Rollback section omitted | liquibase rollback fails silently; downtime | Author rollback per changeset (Step 2) |
runAlways: true on schema or data changes | Re-runs on every deploy | Reserve it for idempotent statements (grants, view refreshes) |
| Editing an applied changeset, then clearing checksums | History no longer matches the database | New changeset; validCheckSum only for harmless edits (Step 3) |
| Mix XML + YAML + JSON in one repo | Reviewer cognitive load; sloppy diffs | Pick one format per repo |
| Skip preconditions on conditional changes | Apply fails on environments where preconditions don't hold | Use dbms/tableExists preconditions (Step 4) |