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
| XML | YAML | JSON | SQL | |
|---|---|---|---|---|
| Verbosity | Highest | Medium | High | Lowest |
| IDE support | Excellent (schema validation) | Good | Good | Good |
| Automatic rollback | Yes | Yes | Yes | No (must write manually) |
| Cross-database portability | Yes | Yes | Yes | No (MySQL-specific syntax) |
| Change type access | Full | Full | Full | None (raw SQL only) |
| Readability | Low | High | Medium | Highest |
| Native SQL control | Limited | Limited | Limited | Full |
| Best for | Enterprise/tooling | Java projects | JS toolchains | DBA-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: The Recommended Format
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
--rollbackyourself - Not portable — MySQL-specific syntax won’t run on PostgreSQL or H2
- No change type features (conditions, preconditions work differently)
ON UPDATE CURRENT_TIMESTAMPcannot 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
--rollbackneeded - SQL format handles seed data and stored procedures cleanly, where raw control matters
- The master changelog is YAML, which includes both
.yamland.sqlfiles
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
--rollbackand is not portable - Mix formats in one project: YAML master changelog can include
.yamlDDL files and.sqldata files ON UPDATE CURRENT_TIMESTAMPrequires SQL format or amodifySqlworkaround 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.