Part 3 of 18

Changelog Formats: XML, YAML, JSON, and SQL — When to Use Each

Liquibase supports four changelog formats: XML, YAML, JSON, and SQL. The format you pick affects readability, tooling support, and what features are available. This article shows the same changeset in all four formats so you can compare them directly — then explains when each format is the right choice.

The Same Change in All Four Formats

To make comparison concrete, here is a single changeset — creating the users table from the e-commerce schema — written in every format.

XML

<?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">

    <changeSet id="20240101-001" author="abhay">
        <comment>Create users table for account management</comment>
        <createTable tableName="users">
            <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(255)">
                <constraints nullable="false"/>
            </column>
            <column name="password_hash" type="VARCHAR(255)">
                <constraints nullable="false"/>
            </column>
            <column name="role" type="VARCHAR(50)" defaultValue="CUSTOMER">
                <constraints nullable="false"/>
            </column>
            <column name="status" type="VARCHAR(50)" defaultValue="ACTIVE">
                <constraints nullable="false"/>
            </column>
            <column name="created_at" type="DATETIME(6)" defaultValueComputed="CURRENT_TIMESTAMP(6)">
                <constraints nullable="false"/>
            </column>
            <column name="updated_at" type="DATETIME(6)" defaultValueComputed="CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)">
                <constraints nullable="false"/>
            </column>
        </createTable>
    </changeSet>

</databaseChangeLog>

YAML

databaseChangeLog:
  - changeSet:
      id: "20240101-001"
      author: abhay
      comment: "Create users table for account management"
      changes:
        - createTable:
            tableName: users
            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: full_name
                  type: VARCHAR(255)
                  constraints:
                    nullable: false
              - column:
                  name: password_hash
                  type: VARCHAR(255)
                  constraints:
                    nullable: false
              - column:
                  name: role
                  type: VARCHAR(50)
                  defaultValue: CUSTOMER
                  constraints:
                    nullable: false
              - column:
                  name: status
                  type: VARCHAR(50)
                  defaultValue: ACTIVE
                  constraints:
                    nullable: false
              - column:
                  name: created_at
                  type: DATETIME(6)
                  defaultValueComputed: "CURRENT_TIMESTAMP(6)"
                  constraints:
                    nullable: false
              - column:
                  name: updated_at
                  type: DATETIME(6)
                  defaultValueComputed: "CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)"
                  constraints:
                    nullable: false

JSON

{
  "databaseChangeLog": [
    {
      "changeSet": {
        "id": "20240101-001",
        "author": "abhay",
        "comment": "Create users table for account management",
        "changes": [
          {
            "createTable": {
              "tableName": "users",
              "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": "DATETIME(6)",
                    "defaultValueComputed": "CURRENT_TIMESTAMP(6)",
                    "constraints": {
                      "nullable": false
                    }
                  }
                }
              ]
            }
          }
        ]
      }
    }
  ]
}

SQL (Formatted SQL)

--liquibase formatted sql

--changeset abhay:20240101-001
--comment Create users table for account management
CREATE TABLE users (
    id          BIGINT NOT NULL AUTO_INCREMENT,
    email       VARCHAR(255) NOT NULL,
    full_name   VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    role        VARCHAR(50) NOT NULL DEFAULT 'CUSTOMER',
    status      VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
    created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    PRIMARY KEY (id),
    UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

--rollback DROP TABLE IF EXISTS users;

Format Comparison

XMLYAMLJSONSQL
VerbosityHighestMediumHighLowest
IDE supportExcellent (schema validation)GoodGoodGood
Automatic rollbackYesYesYesNo (must write manually)
Cross-database portabilityYesYesYesNo (MySQL-specific syntax)
Change type accessFullFullFullNone (raw SQL only)
ReadabilityLowHighMediumHighest
Native SQL controlLimitedLimitedLimitedFull
Best forEnterprise/toolingJava projectsJS toolchainsDBA-written scripts

XML: The Original Format

XML was Liquibase’s original format and has the most mature tooling support. The XSD schema gives you IDE autocompletion and validation — IntelliJ IDEA and Eclipse will flag invalid change type names and attribute typos at edit time.

<!-- The xsi:schemaLocation is what enables IDE validation -->
<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">

Use XML when:

  • Your team uses IntelliJ IDEA or Eclipse and wants schema-driven autocompletion
  • You want strict validation before running anything
  • Your organization has existing XML tooling (XSLT, linters)

Don’t use XML when readability is your top priority — it’s the most verbose format by far, and the tag noise makes code reviews harder.

YAML is the most readable structured format and works well with Git diffs. Adding a column is a few lines; the hierarchy is clear without XML ceremony.

Use YAML when:

  • Your team is comfortable with YAML (common in Java/Kubernetes shops)
  • You want readable code reviews
  • You need platform-agnostic change types (create table, add column, add index)

YAML pitfall — indentation errors: YAML’s biggest weakness is that indentation errors cause silent failures or confusing parse errors. Keep a consistent editor config (2-space indent, no tabs).

# ❌ Common YAML mistake — columns not properly nested under createTable
changes:
  - createTable:
      tableName: users
    columns:         # WRONG — columns is at createTable level, not inside it
      - column:

# ✓ Correct — columns is nested inside createTable
changes:
  - createTable:
      tableName: users
      columns:       # RIGHT — indented one level deeper
        - column:

YAML pitfall — unquoted IDs: IDs that look like numbers must be quoted, or YAML parses them as integers and Liquibase sees a different value than you intended.

# ❌ YAML will parse this as integer 1 — changeset ID becomes "1" not "20240101-001"
id: 20240101-001

# ✓ Quote it
id: "20240101-001"

JSON: Rarely the Right Choice

JSON is valid and fully supported, but it’s the worst of both worlds for changelogs: more verbose than YAML, harder to read than XML (which at least has schema validation). JSON is occasionally useful when you’re generating changelogs programmatically from a JavaScript/TypeScript toolchain.

The one advantage: JSON has no indentation-sensitivity, so programmatically generated changelogs are less likely to have format bugs.

SQL: When You Need Full Control

The SQL format is fundamentally different from the other three. Instead of using Liquibase’s change type abstraction, you write raw SQL directly. Liquibase reads it via special comment annotations.

SQL Format Syntax

--liquibase formatted sql
-- This line MUST be the first line of the file

--changeset author:id
-- One or more changesets per file
-- The changeset runs from this comment to the next --changeset or end of file

--comment Optional human-readable description
--runOnChange:true
--failOnError:true
--dbms:mysql

SELECT 1;  -- your SQL goes here

--rollback SELECT 1; -- rollback SQL (required — no automatic rollback in SQL format)

A Complete SQL Changelog Example

--liquibase formatted sql

--changeset abhay:20240101-001
--comment Create users table
CREATE TABLE users (
    id          BIGINT NOT NULL AUTO_INCREMENT,
    email       VARCHAR(255) NOT NULL,
    full_name   VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    role        VARCHAR(50) NOT NULL DEFAULT 'CUSTOMER',
    status      VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
    created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    PRIMARY KEY (id),
    UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--rollback DROP TABLE IF EXISTS users;

--changeset abhay:20240101-002
--comment Add index on users.status for filtering active accounts
CREATE INDEX idx_users_status ON users (status);
--rollback DROP INDEX idx_users_status ON users;

--changeset abhay:20240101-003
--comment Add full-text index for user search
ALTER TABLE users ADD FULLTEXT INDEX ft_users_name (full_name);
--rollback ALTER TABLE users DROP INDEX ft_users_name;

SQL Format Attributes

All changeset attributes are available as comment annotations:

--changeset abhay:20240101-004
--comment Seed initial admin user
--context prod
--labels feature-auth, sprint-1
--runOnChange:false
--failOnError:true
--dbms:mysql
INSERT INTO users (email, full_name, password_hash, role, status)
VALUES ('admin@example.com', 'System Admin', '$2a$10$...', 'ADMIN', 'ACTIVE');
--rollback DELETE FROM users WHERE email = 'admin@example.com';

Use SQL when:

  • Your DBAs write migrations — they know SQL but not YAML/XML
  • You need MySQL-specific syntax that Liquibase change types don’t support (full-text indexes, spatial types, ON UPDATE CURRENT_TIMESTAMP)
  • You’re migrating from a manual SQL script workflow and want minimal friction
  • You have complex multi-statement migrations where raw SQL is clearer

SQL trade-offs:

  • No automatic rollback — you must write every --rollback yourself
  • Not portable — MySQL-specific syntax won’t run on PostgreSQL or H2
  • No change type features (conditions, preconditions work differently)
  • ON UPDATE CURRENT_TIMESTAMP cannot be expressed in Liquibase change types at all — SQL format is the only way

Mixing Formats in One Project

The master changelog can include files of different formats. The master itself must pick one format, but included files can differ:

# db/changelog/db.changelog-master.yaml (YAML master)
databaseChangeLog:
  - include:
      file: db/changelog/v1.0/users.yaml        # YAML changeset files
      relativeToChangelogFile: false

  - include:
      file: db/changelog/v1.0/seed-data.sql     # SQL for data seeding
      relativeToChangelogFile: false

  - include:
      file: db/changelog/v1.1/products.yaml
      relativeToChangelogFile: false

This is a common pattern: use YAML for DDL changes (platform-agnostic, automatic rollback), use SQL for data migrations (raw control, explicit rollback).

The defaultValueComputed Limitation

One important MySQL-specific issue affects all structured formats (XML, YAML, JSON):

The ON UPDATE CURRENT_TIMESTAMP clause — standard for updated_at columns in MySQL — cannot be expressed with Liquibase change types. The defaultValueComputed attribute handles DEFAULT CURRENT_TIMESTAMP(6) for the default, but the ON UPDATE part requires raw SQL.

Workaround option 1 — use SQL format for tables with updated_at:

--liquibase formatted sql

--changeset abhay:20240101-001
CREATE TABLE users (
    id         BIGINT NOT NULL AUTO_INCREMENT,
    email      VARCHAR(255) NOT NULL,
    updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--rollback DROP TABLE IF EXISTS users;

Workaround option 2 — use YAML with a modifySql block:

changeSet:
  id: "20240101-001"
  author: abhay
  changes:
    - createTable:
        tableName: users
        columns:
          - column:
              name: updated_at
              type: DATETIME(6)
              defaultValueComputed: "CURRENT_TIMESTAMP(6)"
              constraints:
                nullable: false
  modifySql:
    - dbms: mysql
      replace:
        replace: "DEFAULT CURRENT_TIMESTAMP(6) NOT NULL"
        with: "NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)"

modifySql post-processes the generated SQL string. It works but is fragile — if Liquibase changes its SQL generation, the replace string breaks. The SQL format is cleaner for tables that need ON UPDATE.

Which Format for This Series

This series uses YAML for DDL changesets and SQL for data migrations and stored objects. The reasons:

  • YAML is readable in code review and maps clearly to the mental model from Article 2
  • YAML change types provide automatic rollback for DDL — no manual --rollback needed
  • SQL format handles seed data and stored procedures cleanly, where raw control matters
  • The master changelog is YAML, which includes both .yaml and .sql files

Common Mistakes

Mixing formats without quoting IDs: In YAML, any changeset ID that consists of only digits (e.g., 001) must be quoted. In XML, attribute values are always strings. Forgetting quotes in YAML causes ID to be stored as an integer, and the changeset may not match on subsequent runs.

Forgetting --rollback in SQL format: Every SQL changeset should have a --rollback annotation. Omitting it means liquibase rollback will skip it silently (or throw, depending on configuration). Write rollback at the same time you write the change — not as an afterthought.

Using SQL format for portability-sensitive projects: If you ever test against H2 in CI or want to switch databases, SQL format changelogs won’t work — they contain MySQL-specific syntax. Use YAML change types and dbms: mysql for MySQL-only blocks when you need both portability and MySQL-specific behavior.

What You’ve Learned

  • XML offers the best IDE validation via XSD schema but is the most verbose
  • YAML is the most readable structured format — quote all changeset IDs to avoid integer parsing
  • JSON works but adds no advantages over YAML; use it only when generating changelogs programmatically
  • SQL format gives full control and is ideal for DBA-written migrations, data seeding, and stored objects — but requires explicit --rollback and is not portable
  • Mix formats in one project: YAML master changelog can include .yaml DDL files and .sql data files
  • ON UPDATE CURRENT_TIMESTAMP requires SQL format or a modifySql workaround in MySQL

Next: Article 4 — Your First Migration: MySQL Setup and Running liquibase update — connecting to MySQL, writing your first real changeset, and watching it execute.