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

added_entity_<Entity>

New table for a new entity (no foreign keys yet)

added_entity_constraints_<Entity>

Foreign-key / unique / check constraints for a just-added table — a sibling file with the same timestamp

alter_entity_<Entity>

addColumn / dropColumn / modifyDataType / renameColumn on an existing table

alter_entity_constraints_<Entity>

Add or remove constraints on an existing table

load_<snake-name>

Seed-data insert

migrate_<from>_to_<to>

Data transform (rows, not schema)

add_<feature>_<Entity>

Ad-hoc feature change where alter_entity does not fit

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 literal datetime(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>

<column name="x" value="NULL"/> writes the literal string 'NULL', not SQL NULL. To set a real NULL, use raw SQL: <sql>UPDATE t SET col = NULL</sql>.

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

  1. Build the module that owns the changelog (mvn -DskipTests compile) — a malformed include or XML fails fast.

  2. If a column maps to a JPA field, build the service too — entity/schema mismatches surface at start-up.

  3. 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 remarks on new columns and tables — the next reader cannot tell what a column means.

  • A new file with no master <include>.