Liquibase Changelog: A Practical Tutorial
Chat2DB TeamLiquibase tracks database schema changes the way Git tracks code changes: as an ordered sequence of discrete, identified units that have either been applied or not. The changelog is that sequence. Understanding how Liquibase decides what to run — and what makes it refuse — is most of what you need to use it well.
This tutorial covers the changelog structure, the four file formats, and the practices that separate migrations that work in production from migrations that cause an incident at 3am.
How Liquibase decides what to run
When you run liquibase update, Liquibase:
- Creates two tables if they do not exist:
DATABASECHANGELOG(a record of applied changeSets) andDATABASECHANGELOGLOCK(a mutex so two deploys cannot migrate at once). - Reads the changelog and walks it in order.
- For each changeSet, computes a checksum of its contents and looks for a matching
id+author+filenameinDATABASECHANGELOG. - If there is no record, runs it and inserts a row. If there is a record and the checksum matches, skips it. If there is a record and the checksum differs, aborts with a validation error.
That last case is the one that catches people, and it is deliberate. Liquibase is telling you that a changeSet which already ran against this database has been edited since. It cannot know whether the edit is harmless or whether the database is now in a state the changelog no longer describes, so it refuses to continue.
The rule that follows is the single most important one in Liquibase:
Never edit a changeSet that has been applied anywhere. Add a new one instead.
Anatomy of a changeSet
A changeSet is identified by the triple of id, author and the changelog file path. None of them need to be meaningful to Liquibase — id can be 1, a ticket number, or a timestamp — but the combination must be unique.
<changeSet id="2026-09-21-create-customer" author="alice">
<comment>Initial customer table for the billing service</comment>
<createTable tableName="customer">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="email" type="VARCHAR(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="full_name" type="VARCHAR(120)">
<constraints nullable="false"/>
</column>
<column name="country_code" type="CHAR(2)" defaultValue="US">
<constraints nullable="false"/>
</column>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMP WITH TIME ZONE"
defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<rollback>
<dropTable tableName="customer"/>
</rollback>
</changeSet>Note defaultValueComputed for now() rather than defaultValue. defaultValue emits a quoted literal — you would get the string 'now()' rather than a call to the function. Getting this wrong produces a column whose default is the six-character text now(), which fails on a timestamp column and, worse, silently succeeds on a text column.
The attribute variants are:
| Attribute | Use for |
|---|---|
defaultValue | string literals |
defaultValueNumeric | numbers |
defaultValueBoolean | true / false |
defaultValueDate | date literals |
defaultValueComputed | SQL expressions and function calls |
One change per changeSet
<!-- Don't do this -->
<changeSet id="5" author="alice">
<createTable tableName="orders">...</createTable>
<createIndex indexName="idx_orders_customer" tableName="orders">...</createIndex>
<addForeignKeyConstraint .../>
</changeSet>If the index creation fails, Liquibase's behaviour depends on whether your database supports transactional DDL. On PostgreSQL the whole changeSet rolls back cleanly. On MySQL, which auto-commits DDL, the table exists but the changeSet is not recorded — so re-running tries to create the table again and fails. You are left hand-editing DATABASECHANGELOG to recover.
Splitting them means each unit succeeds or fails independently and is recorded independently:
<changeSet id="5" author="alice">
<createTable tableName="orders">...</createTable>
<rollback><dropTable tableName="orders"/></rollback>
</changeSet>
<changeSet id="6" author="alice">
<createIndex indexName="idx_orders_customer" tableName="orders">
<column name="customer_id"/>
</createIndex>
<rollback><dropIndex indexName="idx_orders_customer" tableName="orders"/></rollback>
</changeSet>The four formats
The same changeSet in each format.
XML
Fullest tooling and XSD validation, which means your IDE autocompletes and catches typos before you run anything.
<?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">
<include file="changes/001-customer.xml" relativeToChangelogFile="true"/>
<include file="changes/002-orders.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>YAML
Same semantics, less ceremony.
databaseChangeLog:
- changeSet:
id: "2026-09-21-create-customer"
author: alice
changes:
- createTable:
tableName: customer
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: email
type: VARCHAR(255)
constraints:
nullable: false
unique: true
- column:
name: created_at
type: TIMESTAMP WITH TIME ZONE
defaultValueComputed: now()
constraints:
nullable: false
rollback:
- dropTable:
tableName: customerJSON
Rarely hand-written, but useful when changelogs are generated by tooling.
Formatted SQL
Plain SQL with magic comments. You give up database-agnostic types and automatic rollback generation, and gain the ability to write anything your database supports.
--liquibase formatted sql
--changeset alice:2026-09-21-create-customer
CREATE TABLE customer (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(120) NOT NULL,
country_code CHAR(2) NOT NULL DEFAULT 'US',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--rollback DROP TABLE customer;
--changeset alice:2026-09-21-orders-index
CREATE INDEX idx_orders_customer_placed ON orders (customer_id, placed_at);
--rollback DROP INDEX idx_orders_customer_placed;Which to choose
XML if you want validation and the broadest tooling, and you target more than one database.
YAML if your team finds XML painful and you still want database-agnostic changes.
Formatted SQL if you are single-database and want full control over the exact DDL — particularly if you need constructs Liquibase does not model, such as CREATE INDEX CONCURRENTLY or partitioned tables.
A common and sensible hybrid is YAML or XML for ordinary structural changes and <sqlFile> for the handful that need raw SQL:
<changeSet id="42" author="alice">
<sqlFile path="sql/create-partitioned-events.sql"
relativeToChangelogFile="true"
splitStatements="true"
endDelimiter=";"/>
<rollback>
<sql>DROP TABLE events;</sql>
</rollback>
</changeSet>If you already have the DDL and want a starting changelog rather than writing one by hand, a Liquibase changelog generator (opens in a new tab) will turn CREATE TABLE and CREATE INDEX statements into properly structured changeSets with constraints and rollbacks in any of the four formats — a reasonable way to bootstrap an existing schema before hand-tuning.
Organising a changelog
A single file grows unmanageable. The standard layout is a root changelog that includes others:
db/changelog/
├── db.changelog-master.xml
└── changes/
├── 001-customer.xml
├── 002-orders.xml
├── 003-add-order-status.xml
└── 004-seed-reference-data.xml<databaseChangeLog ...>
<includeAll path="changes/" relativeToChangelogFile="true"/>
</databaseChangeLog>includeAll picks up files in alphabetical order, which is why numeric prefixes matter. Explicit <include> is more verbose but makes ordering unambiguous and survives someone adding a file with an unexpected name — a reasonable trade for production.
Because the changelog filename is part of a changeSet's identity, renaming or moving a changelog file makes Liquibase treat every changeSet in it as new. It will try to re-run them all. If you must reorganise, use logicalFilePath to pin the recorded identity:
<databaseChangeLog logicalFilePath="changes/001-customer.xml" ...>Rollbacks
Liquibase generates rollback statements automatically for changes where the inverse is unambiguous: createTable → dropTable, addColumn → dropColumn, createIndex → dropIndex.
It cannot for anything that destroys information. dropTable, dropColumn, delete and raw <sql> all need an explicit <rollback>:
<changeSet id="20" author="alice">
<dropColumn tableName="customer" columnName="legacy_ref"/>
<rollback>
<addColumn tableName="customer">
<column name="legacy_ref" type="VARCHAR(64)"/>
</addColumn>
</rollback>
</changeSet>That rollback restores the column but not the data — which is honest, because the data is gone. Where a change is genuinely irreversible, say so explicitly rather than leaving it undefined:
<changeSet id="21" author="alice">
<sql>DELETE FROM audit_log WHERE created_at < now() - interval '2 years';</sql>
<rollback>
<empty/>
</rollback>
</changeSet>Rolling back:
liquibase rollback-count 1
liquibase rollback-to-date 2026-09-01
liquibase rollback my-release-tag
liquibase rollback-sql-count 1 # preview without executingTag before each release so you always have a named point to return to:
liquibase tag v2.4.0
liquibase update
# if it goes wrong
liquibase rollback v2.4.0A realistic caveat: rollback works well for structural changes and poorly for anything involving data. In practice, many teams treat rollback as a development convenience and handle production recovery by rolling forward with a corrective changeSet. Write rollbacks anyway — they cost little and occasionally save an afternoon — but do not build your deployment strategy on the assumption that a production rollback will restore lost data.
Preconditions
Preconditions guard a changeSet against running when the database is not in the expected state:
<changeSet id="30" author="alice">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="customer" columnName="phone"/>
</not>
</preConditions>
<addColumn tableName="customer">
<column name="phone" type="VARCHAR(32)"/>
</addColumn>
</changeSet>onFail options:
HALT(default) — stop with an errorCONTINUE— skip this changeSet, try again next runMARK_RAN— skip it and record it as appliedWARN— log a warning and run it anyway
MARK_RAN is the useful one when adopting Liquibase against a database that already has some of the schema. Guard each changeSet with a precondition checking whether its object exists; Liquibase marks the existing ones as applied and runs only the genuinely missing ones.
Preconditions at the changelog level apply globally:
<preConditions>
<dbms type="postgresql"/>
<runningAs username="liquibase_user"/>
</preConditions>Contexts and labels
Both let you run a subset of changeSets, and the difference matters.
Contexts describe where a changeSet should run:
<changeSet id="40" author="alice" context="dev,test">
<insert tableName="customer">
<column name="email" value="test@example.com"/>
<column name="full_name" value="Test User"/>
</insert>
</changeSet>liquibase update --contexts=dev
liquibase update --contexts=prodLabels describe what a changeSet is, and are filtered with a richer expression syntax:
<changeSet id="41" author="alice" labels="v2.4,billing">
...
</changeSet>liquibase update --label-filter="v2.4 and billing"
liquibase update --label-filter="!experimental"Use contexts for environment targeting (seed data in dev, not in prod) and labels for release or feature grouping.
Zero-downtime changes
Liquibase applies whatever DDL you write, and some DDL locks tables. The deployment-safety problem is yours, not Liquibase's.
Adding a NOT NULL column in one step requires a table rewrite and a default for every existing row. On a large table this blocks writes for the duration. Split it across releases:
<!-- Release 1: nullable column, no rewrite -->
<changeSet id="50" author="alice">
<addColumn tableName="orders">
<column name="channel" type="VARCHAR(32)"/>
</addColumn>
</changeSet>
<!-- Release 2: backfill in batches -->
<changeSet id="51" author="alice" runInTransaction="false">
<sql>
UPDATE orders SET channel = 'web'
WHERE channel IS NULL AND id IN (
SELECT id FROM orders WHERE channel IS NULL LIMIT 10000
);
</sql>
<rollback><empty/></rollback>
</changeSet>
<!-- Release 3: enforce, once the application always writes it -->
<changeSet id="52" author="alice">
<addNotNullConstraint tableName="orders" columnName="channel"
columnDataType="VARCHAR(32)"/>
</changeSet>Creating an index on PostgreSQL locks the table against writes unless done concurrently — and CREATE INDEX CONCURRENTLY cannot run inside a transaction:
<changeSet id="53" author="alice" runInTransaction="false">
<sql>CREATE INDEX CONCURRENTLY idx_orders_channel ON orders (channel);</sql>
<rollback>DROP INDEX CONCURRENTLY idx_orders_channel;</rollback>
</changeSet>runInTransaction="false" is required. Without it Liquibase wraps the statement in a transaction and PostgreSQL rejects it.
Renaming a column breaks any running application instance that still uses the old name. During a rolling deploy, both versions run simultaneously. Use the expand-and-contract pattern: add the new column, write to both, backfill, switch reads, then drop the old column in a later release.
Commands worth knowing
liquibase update # apply pending changeSets
liquibase update-sql # print the SQL without running it
liquibase status --verbose # what is pending
liquibase validate # check the changelog for problems
liquibase history # what has been applied
liquibase tag v2.4.0
liquibase rollback-sql-count 1 # preview a rollback
liquibase diff # compare two databases
liquibase generate-changelog # reverse-engineer an existing schema
liquibase clear-checksums # recompute on next run — use with careupdate-sql should be part of your deployment pipeline. It prints exactly what Liquibase will execute, which turns a migration into something reviewable rather than something you find out about afterwards.
generate-changelog is the standard way to adopt Liquibase against an existing database:
liquibase generate-changelog --changelog-file=baseline.xml
liquibase changelog-sync # mark it all as applied without running itchangelog-sync is the crucial second step — it records the baseline as already applied so Liquibase does not try to recreate a schema that exists.
Verifying the result
After a migration, confirm the database matches expectations rather than trusting the exit code:
-- What did Liquibase record?
SELECT id, author, filename, dateexecuted, exectype, description
FROM databasechangelog
ORDER BY orderexecuted DESC
LIMIT 10;
-- Is the lock stuck? (after a killed migration)
SELECT * FROM databasechangeloglock;A crashed migration can leave LOCKED = true, blocking every future run with "Could not acquire change log lock". Release it once you are sure nothing is actually running:
liquibase release-locksFor checking the resulting schema itself — column types, constraints, indexes actually created — a client that shows structure directly is faster than writing information_schema queries. Chat2DB (opens in a new tab) connects to PostgreSQL, MySQL, Oracle and SQL Server, so you can verify the same changelog produced what you expected on each target; the web version (opens in a new tab) works without an install.
Summary
Liquibase's model is simple once the identity rule is clear: a changeSet is id + author + filename, its contents are checksummed, and applied changeSets are immutable. Everything else follows from that.
Keep one logical change per changeSet so partial failures are recoverable. Use defaultValueComputed for expressions. Write explicit rollbacks for destructive changes and <empty/> for genuinely irreversible ones. Use runInTransaction="false" for CREATE INDEX CONCURRENTLY and batched backfills. Guard with preconditions when adopting against an existing schema. Run update-sql in code review so migrations are reviewed rather than discovered. And split risky changes — nullable column, backfill, then constraint — across releases so no single deploy holds a long lock.
