Changelog and change set conventions
One change set file per logical change, a timestamp-prefixed filename, a unique (id, author, filename) tuple, database-portable column types, and an entry in the master changelog — in apply order.
A change set, once applied to any database, is immutable; corrections are always new change sets (see Change set immutability).
Orient in the repository
The master changelog `<include>`s every per-change file in order. Locate it and how it is wired before adding anything:
# typical locations / how it is wired into Spring Boot
grep -rl "databaseChangeLog" src/main/resources --include=*.xml | head
grep -i "change-log" src/main/resources/config/application*.yml # spring.liquibase.change-log
Read the master once and note two things you will reuse: the directory new change set files live in (commonly …/liquibase/changelog/), and the declared <property> aliases at the top of the master (see Database-portable types — use declared properties, never hardcode).
Filename convention
YYYYMMDDHHMMSS_<kind>_<Entity>.xml — a 14-digit timestamp so files sort in apply order.
Two changes on the same day: bump the HHMMSS so the new file sorts after the previous one.
| Kind | Use for |
|---|---|
|
New table for a new entity (no foreign keys yet) |
|
Foreign-key / unique / check constraints for a just-added table — a sibling file with the same timestamp |
|
|
|
Add or remove constraints on an existing table |
|
Seed-data insert |
|
Data transform (rows, not schema) |
|
Ad-hoc feature change where |
Keeping constraints in a separate sibling file lets you re-order table creation versus constraint creation on databases where a circular foreign key would otherwise block a load.
Change set id rule
id="<timestamp>-<ordinal>", where the ordinal starts at 1 and increments across change sets within one file.
Liquibase requires the (id, author, filename) tuple to be unique across all history. The author is the developer’s handle, used consistently.
Standard file header
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
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
http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">
<!--
Short plain-English description. Cite the work-item / ticket id.
Reference the design doc for context if one exists.
-->
<changeSet id="<timestamp>-1" author="<handle>">
<!-- change operations -->
</changeSet>
</databaseChangeLog>
Database-portable types — use declared properties, never hardcode
The single biggest portability trap is hardcoding a database-specific type or function — date/time types especially — because the dev database is often H2 while staging and production are MySQL.
A correctly configured master changelog declares dbms-qualified <property> aliases so the same change set renders correctly on every target:
<property name="now" value="now()" dbms="mysql,h2"/>
<property name="now" value="sysdate" dbms="oracle"/>
<property name="datetimeType" value="datetime" dbms="h2"/>
<property name="datetimeType" value="datetime(6)" dbms="mysql"/>
<property name="floatType" value="float4" dbms="postgresql,h2"/>
<property name="floatType" value="float" dbms="mysql,oracle,mssql,mariadb"/>
<property name="uuidType" value="binary(16)" dbms="mysql"/>
Rules:
-
Timestamps — use
type="${datetimeType}"(or the project’s alias) for every date/time column, never a literaldatetime(3)/timestamp. This keeps fractional-second precision consistent across databases. -
Default timestamps — use
defaultValueComputed="${now}", not a literal function call. -
float / uuid / clob / blob — use the declared alias where one exists.
-
If you need a type the master does not alias yet and it differs across databases, add a new
dbms-qualified<property>to the master rather than hardcoding it in the change set. -
When adding a foreign-key column, match the referenced column’s type exactly, including any unsigned or length qualifiers the project uses for inherited or external tables.
Common operations
Add a column
The most common change. Always set remarks, and give a defaultValue so a NOT NULL constraint is satisfiable for existing rows:
<changeSet id="20260422100000-1" author="me">
<addColumn tableName="race_number">
<column name="state" type="varchar(1)" defaultValue="S"
remarks="Lifecycle state code; see RaceNumberState enum">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
Create a table
Set remarks on the table and every column, use ${datetimeType} for timestamps, and start the primary key well above any dev/fixture id range to avoid collisions:
<changeSet id="20260330100000-1" author="me">
<createTable tableName="person_merge_log" remarks="Audit trail for merges">
<column name="id" type="bigint" autoIncrement="true" startWith="1500">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="merge_date_time" type="${datetimeType}" remarks="When executed">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
Index and foreign key
Foreign keys belong in the sibling constraints file:
<addForeignKeyConstraint baseTableName="person_merge_log" baseColumnNames="source_person_id"
constraintName="fk_pml_source" referencedTableName="user" referencedColumnNames="id"/>
Data migration
Keep data migrations in a separate file from DDL, and wrap risky writes with a precondition so a re-run is a no-op:
<changeSet id="20260422100100-1" author="me">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM race_number WHERE state IS NULL</sqlCheck>
</preConditions>
<sql>UPDATE race_number SET state = 'D' WHERE ...;</sql>
</changeSet>
|
|
Register in the master changelog
Add the <include> before any test/fixture-data block (which stays last), grouped under a comment citing the feature:
<!-- Race number lifecycle state (Feature #473 / Task #479) -->
<include file="liquibase/changelog/20260422100000_alter_entity_RaceNumber_state.xml"
relativeToChangelogFile="false"/>
Use relativeToChangelogFile="false" so paths resolve from the changelog root.
A new file with no master <include> is the classic "works locally, missing in staging" bug — the change applies on a freshly reset dev database but never reaches a long-lived environment.
Verify before commit
-
Build the module that owns the changelog (
mvn -DskipTests compile) — a malformed include or XML fails fast. -
If a column maps to a JPA field, build the service too — entity/schema mismatches surface at start-up.
-
For data migrations against real data, dry-run the SQL against a clone of the target database first.
Anti-patterns
-
Editing an already-applied change set or its CSV — breaks start-up. See Change set immutability.
-
DDL and data migration in one change set — split into two files for clean rollback paths.
-
Hardcoded database-specific date/time types instead of
${datetimeType}/${now}. -
Missing
remarkson new columns and tables — the next reader cannot tell what a column means. -
A new file with no master
<include>.