# Liquibase, From Zero

> Database migrations with Liquibase: changesets in SQL, YAML, or XML, a tracked changelog, and database-agnostic changes when you need to target more than one engine.


---

# Liquibase, From Zero

You have a database that already has tables in it, a team that keeps changing the schema, and a deploy that breaks the moment two people edit the same column. You have heard Liquibase can track all of this for you, but the first thing you saw was an XML file with twelve namespaces and you closed the tab. This guide gets you past that. We will treat Liquibase as what it actually is: a ledger of small, ordered changes that the database remembers it has run.

## How to read this

Read it in order the first time. Phase 1 builds the mental model, the changelog and the changeset, so the rest stops looking like magic. Phase 2 is the loop you will live in: write a change, run it, check status, roll back. Phase 3 is where the abstraction either pays for itself or gets in your way, contexts, labels, database-agnostic change types, and the gotchas that page you at 2am. If you already run migrations with another tool, skim Phase 1 and slow down at the contrast sections.

If migrations as a concept are new to you, read [the migrations guide](/guides/database-migrations) first; this guide assumes you know *why* you would version a schema and focuses on *how Liquibase does it*.

## The phases

1. [The mental model: changelog and changeset](01-the-changelog-and-the-changeset.md) - what Liquibase actually tracks and why it never re-runs a change.
2. [The everyday loop: write, update, status, rollback](02-the-everyday-loop.md) - the commands and changeset patterns you use daily.
3. [When the abstraction earns its keep](03-when-abstraction-earns-its-keep.md) - contexts, labels, DB-agnostic changes, and the gotchas of production.


---

# The mental model: changelog and changeset

Here is the reality Liquibase is built for. Your schema is not a static thing you design once. It grows: a column here, an index there, a table you regret and drop three months later. Five developers make these edits on their own machines, then those edits have to land on staging, then production, in the right order, without anyone running the same `ALTER TABLE` twice. The question Liquibase answers is narrow and important: *which of these changes has this particular database already applied, and which still need to run?*

Everything else, the XML, the YAML, the abstraction, is in service of that one question.

## Two nouns: the changelog and the changeset

A **changeset** is one change. Add a table. Add a column. Create an index. It is the atomic unit, the thing that either has run against a database or has not.

A **changelog** is the ordered list of every changeset, top to bottom. It is a file you commit to source control. It is the master plan of how your schema came to be, from empty database to today.

That is the whole model. A changelog is a list; each item in the list is a changeset; Liquibase walks the list and runs the ones a given database has not seen yet.

```text
changelog (db/changelog.yaml)  ← committed to git, the master list
├── changeset 1   create table "author"
├── changeset 2   create table "book"
├── changeset 3   add column "book.isbn"
└── changeset 4   create index on "book.author_id"
```

*What just happened:* you are looking at the entire conceptual structure. Order matters because changeset 3 cannot add a column to a table that changeset 1 has not created yet. Liquibase runs them top to bottom and never reorders them.

## How a database remembers what it ran

When Liquibase runs against a database for the first time, it creates two tracking tables. You do not write these; Liquibase manages them.

- `DATABASECHANGELOG` - one row per changeset that has been applied. This is the ledger.
- `DATABASECHANGELOGLOCK` - a single-row lock so two Liquibase processes cannot run migrations against the same database at the same time.

When you run an update, Liquibase reads your changelog file, reads the `DATABASECHANGELOG` table, and computes the difference. Anything in the file but not in the table gets run. Anything already in the table gets skipped.

```text
$ liquibase status --verbose

3 changesets have not been applied to app@jdbc:postgresql://localhost/app
     db/changelog.yaml::3::you
     db/changelog.yaml::4::you
     db/changelog.yaml::5::you
```

*What just happened:* Liquibase compared the file to the ledger and told you exactly which changesets this database is missing. The triple `file::id::author` is how every changeset is identified, and you will see that identifier everywhere.

> A changeset's identity is the combination of its `id`, its `author`, and the changelog file path, not the SQL inside it. This is the single most important fact about Liquibase. Change the SQL of an already-applied changeset and Liquibase will *not* notice or re-run it; it only checks whether that `id` + `author` + file has a row in the ledger. We will come back to why this trips people up.

## A changeset is the same idea in three dialects

Liquibase lets you write changesets in SQL, YAML, XML, or JSON. They describe the same thing. Here is "create a `book` table" in three forms so the shape is concrete.

Formatted SQL, the form most people start with:

```sql
--liquibase formatted sql

--changeset alice:1
CREATE TABLE book (
    id     BIGINT PRIMARY KEY,
    title  VARCHAR(255) NOT NULL
);
```

*What just happened:* the comments are not decoration. The `--liquibase formatted sql` header tells Liquibase this `.sql` file is a changelog, and `--changeset alice:1` marks where one changeset begins, with author `alice` and id `1`. The SQL between markers is run verbatim against your database.

The same change in YAML:

```yaml
databaseChangeLog:
  - changeSet:
      id: 1
      author: alice
      changes:
        - createTable:
            tableName: book
            columns:
              - column: { name: id, type: BIGINT, constraints: { primaryKey: true } }
              - column: { name: title, type: VARCHAR(255), constraints: { nullable: false } }
```

*What just happened:* instead of raw SQL you described the change with `createTable`, one of Liquibase's built-in **change types**. Liquibase generates the correct `CREATE TABLE` for whatever database you point it at. This is the abstraction that separates Liquibase from a plain SQL migration tool, and Phase 3 is about when it is worth the extra ceremony.

## The contrast that explains Liquibase

If you have used Flyway (or any "numbered SQL files" tool), the difference is sharp and worth naming early, because it is the whole reason to choose one over the other.

| | Numbered-SQL tools (e.g. Flyway) | Liquibase |
|---|---|---|
| A migration is | a `.sql` file, raw SQL only | a changeset, in SQL **or** an abstract change type |
| Targets one DB engine | SQL is hand-written per engine | abstract change types generate per-engine SQL |
| Rollback | you write a separate "undo" script (or pay for it) | many changes auto-generate their rollback |
| Selective runs | run everything up to a version | tag changesets with **contexts** and **labels** |

*What just happened:* the table is the thesis of this whole guide. Liquibase trades simplicity (raw SQL files) for abstraction (change types, rollback, contexts). That trade is sometimes a gift and sometimes a tax. If you only ever ship to one database and you are comfortable in SQL, the tax can outweigh the gift, and that is a legitimate reason to reach for a simpler tool. The rest of this guide helps you feel where the line is.

For builders: the abstraction earns its keep most clearly when one codebase must run on more than one engine, say PostgreSQL in production and H2 in your tests. A `createTable` change type produces valid SQL for both from a single definition. Hold that example; Phase 3 returns to it.

## What you can ignore for now

The first time you open a Liquibase XML example you will see a `<databaseChangeLog>` element draped in `xmlns` and `xsi:schemaLocation` attributes. That is XML namespace boilerplate, the same for every project, and you can copy it once and never think about it again. It does not affect how migrations behave. Pick the format you find readable, SQL and YAML are the gentlest, and move on.

```quiz
[
  {
    "q": "What identifies a changeset to Liquibase?",
    "choices": [
      "The SQL or change content inside it",
      "Its id plus author plus changelog file path",
      "Its position number in the file",
      "A hash of the whole changelog"
    ],
    "answer": 1,
    "explain": "Identity is id + author + file path. Liquibase checks whether that identifier exists in DATABASECHANGELOG, not whether the content changed."
  },
  {
    "q": "What does the DATABASECHANGELOG table store?",
    "choices": [
      "A backup copy of every table you create",
      "One row per changeset that has been applied to that database",
      "The full text of your changelog file",
      "A lock preventing concurrent runs"
    ],
    "answer": 1,
    "explain": "It is the ledger: one row per applied changeset. The separate DATABASECHANGELOGLOCK table is what prevents concurrent runs."
  },
  {
    "q": "Compared with a numbered-SQL tool, what does Liquibase add?",
    "choices": [
      "Faster raw SQL execution",
      "Abstract change types, auto-rollback, and contexts/labels",
      "Automatic database backups",
      "A required GUI"
    ],
    "answer": 1,
    "explain": "Liquibase trades raw-SQL simplicity for abstraction: DB-agnostic change types, generated rollbacks, and context/label targeting."
  }
]
```


---

# The everyday loop: write, update, status, rollback

Phase 1 gave you the nouns. This phase is the verbs, the four-step rhythm you will repeat hundreds of times: add a changeset, preview it, apply it, and undo it when you got something wrong. Once this loop is in your hands, Liquibase stops being a configuration puzzle and becomes a tool you barely think about.

## Telling Liquibase where the database is

Before any command runs, Liquibase needs to know two things: which changelog file to read and which database to talk to. The usual home for that is a `liquibase.properties` file in your project root.

```text
changeLogFile=db/changelog.yaml
url=jdbc:postgresql://localhost:5432/app
username=app
password=secret
```

*What just happened:* you pointed Liquibase at your master changelog and gave it JDBC connection details. With this file present, you run commands with no extra flags. In real projects the password comes from an environment variable or a secrets manager, never committed, but the four keys above are the whole contract.

## Step one: write a changeset

You add new changesets to the *bottom* of the changelog. Never edit one that has already run; append a new one instead. (Phase 3 explains the bruise behind that rule.) Say you need an `email` column on your `author` table.

```yaml
  - changeSet:
      id: 10
      author: alice
      changes:
        - addColumn:
            tableName: author
            columns:
              - column:
                  name: email
                  type: VARCHAR(320)
```

*What just happened:* one new changeset, appended after the existing ones. It has a fresh `id`, so Liquibase treats it as something this database has not seen. The old changesets are untouched, exactly as they should be.

## Step two: preview before you touch anything

The single most reassuring command in Liquibase is `update-sql`. It shows you the exact SQL it *would* run, without running it.

```text
$ liquibase update-sql

-- *********************************************************************
-- Update Database Script
-- *********************************************************************
-- Lock Database
UPDATE databasechangeloglock SET locked = TRUE ...;
-- Changeset db/changelog.yaml::10::alice
ALTER TABLE author ADD email VARCHAR(320);
INSERT INTO databasechangelog (id, author, filename, ...) VALUES ('10', 'alice', ...);
-- Release Database Lock
UPDATE databasechangeloglock SET locked = FALSE ...;
```

*What just happened:* Liquibase generated the actual `ALTER TABLE` plus the bookkeeping it does around every run, take the lock, apply the change, record it in the ledger, release the lock. Reading this output before a production deploy is the difference between confidence and a 2am surprise. Make it a habit.

## Step three: apply it

When the preview looks right, run the real thing.

```text
$ liquibase update

Running Changeset: db/changelog.yaml::10::alice
ALTER TABLE author ADD email VARCHAR(320)

Liquibase command 'update' was executed successfully.
```

*What just happened:* the changeset ran and a new row landed in `DATABASECHANGELOG`. Run `liquibase update` again right now and nothing happens, the ledger already has changeset 10, so there is nothing new to apply. That idempotence is the whole point: the same command is safe to run on every database in every environment, and each only gets what it is missing.

## Step four: rolling back

This is where Liquibase pulls ahead of raw-SQL tools. Many change types know how to undo themselves. An `addColumn` rollback is a `DROP COLUMN`; a `createTable` rollback is a `DROP TABLE`. You did not write that undo logic, Liquibase derived it.

```text
$ liquibase rollback-count 1

Rolling Back Changeset: db/changelog.yaml::10::alice
ALTER TABLE author DROP COLUMN email

Liquibase command 'rollbackCount' was executed successfully.
```

*What just happened:* Liquibase undid the most recent changeset and deleted its row from the ledger, so `status` now reports it as pending again. You can also roll back to a tag (`rollback <tagname>`) or by date. The ledger and the rollback stay in sync automatically.

The catch: auto-rollback only works for changes Liquibase can reverse. Raw SQL it cannot. If a changeset is hand-written SQL, or does something inherently lossy like dropping a column full of data, you must supply the undo yourself with a `rollback` block.

```yaml
  - changeSet:
      id: 11
      author: alice
      changes:
        - sql:
            sql: UPDATE author SET email = LOWER(email)
      rollback:
        - sql:
            sql: SELECT 1
```

*What just happened:* because raw SQL has no automatic inverse, you declared the rollback explicitly. Here the update is not reversible (the old casing is gone), so the rollback is a deliberate no-op, `SELECT 1` does nothing, documenting that this change cannot be undone. Being upfront about that in the changelog beats a rollback that silently corrupts data.

> Test your rollbacks. A rollback you have never run is a guess, not a safety net. Many teams run `update` then `rollback` in CI against a throwaway database so every changeset is proven reversible (or proven irreversible on purpose) before it reaches production.

## The whole loop at a glance

```text
write changeset  →  liquibase update-sql   (preview, runs nothing)
                 →  liquibase update        (apply, records in ledger)
                 →  liquibase status        (confirm: 0 pending)
   regret it?    →  liquibase rollback-count 1
```

*What just happened:* that is the entire daily rhythm. Four commands cover almost everything you will do. The connection points to a [migrations workflow](/guides/database-migrations) in general; Liquibase's contribution is making the preview and the rollback first-class instead of files you maintain by hand.

```quiz
[
  {
    "q": "What does `liquibase update-sql` do?",
    "choices": [
      "Applies all pending changesets",
      "Prints the SQL it would run without executing it",
      "Rolls back the last changeset",
      "Updates the Liquibase binary"
    ],
    "answer": 1,
    "explain": "update-sql is a dry run: it generates and prints the exact SQL (and ledger updates) without touching the database."
  },
  {
    "q": "You run `liquibase update`, then run it again immediately. What happens the second time?",
    "choices": [
      "It re-applies every changeset",
      "It errors because the schema already changed",
      "Nothing new runs; the ledger already has those changesets",
      "It rolls everything back"
    ],
    "answer": 2,
    "explain": "update is idempotent. Applied changesets are in DATABASECHANGELOG, so a second run finds nothing pending."
  },
  {
    "q": "For a changeset written as raw SQL, how does rollback work?",
    "choices": [
      "Liquibase auto-generates the inverse SQL",
      "Rollback is impossible for any SQL changeset",
      "You must supply a rollback block yourself",
      "It silently skips the rollback"
    ],
    "answer": 2,
    "explain": "Auto-rollback only works for reversible change types. Raw SQL has no known inverse, so you declare the rollback explicitly."
  }
]
```


---

# When the abstraction earns its keep

You now have the model and the loop. This last phase is about judgment, the features that justify Liquibase's extra weight, and the gotchas that turn a calm Tuesday into an incident. The plain framing: Liquibase's abstraction is a cost you pay on every changeset, and the skill is knowing when it buys you something worth more than the cost.

## Database-agnostic changes: the headline feature

The reason to write `createTable` instead of `CREATE TABLE` is portability. One changeset, many engines. The classic case is testing: production runs PostgreSQL, but your test suite spins up an in-memory H2 database for speed. With abstract change types, the *same* changelog builds the schema in both.

```yaml
  - changeSet:
      id: 20
      author: alice
      changes:
        - createTable:
            tableName: session
            columns:
              - column: { name: id, type: UUID, constraints: { primaryKey: true } }
              - column: { name: created_at, type: TIMESTAMP WITH TIME ZONE }
```

*What just happened:* Liquibase translates `UUID` and `TIMESTAMP WITH TIME ZONE` into whatever each target engine actually calls those types. You wrote the intent once; Liquibase emitted the correct dialect for PostgreSQL and for H2. If you had hand-written SQL, you would maintain two copies and pray they stayed in step.

Here is the flip side, and it is the whole decision in one sentence. If you only ever target **one** database, this translation buys you nothing and costs you readability, because a `createTable` block is harder to scan than the `CREATE TABLE` you already know. The abstraction earns its keep when you target more than one engine, or genuinely expect to. Otherwise, Liquibase still lets you write plain SQL changesets, and that is often the wiser choice.

> A useful default: write portable change types for structural changes (tables, columns, indexes) where the translation is reliable, and drop to raw SQL for anything engine-specific (a PostgreSQL `GIN` index, a stored procedure). Mixing is allowed and normal.

## Contexts and labels: shipping a subset

Sometimes you do not want every changeset to run everywhere. Seed data belongs in dev and test, not production. A changeset for a feature still behind a flag should wait. Liquibase gives you two filters.

- **Contexts** describe *where* a changeset should run (an environment-ish tag).
- **Labels** describe *what* a changeset is (a categorization you query at run time).

```yaml
  - changeSet:
      id: 30
      author: alice
      context: "dev,test"
      labels: "seed"
      changes:
        - insert:
            tableName: author
            columns:
              - column: { name: id, value: 1 }
              - column: { name: name, value: "Test Author" }
```

*What just happened:* this insert is tagged with context `dev,test` and label `seed`. Run `liquibase update --contexts=dev` on your laptop and it executes; run `liquibase update --contexts=prod` on production and Liquibase skips it. The seed data never reaches production, from a single shared changelog.

```text
$ liquibase update --contexts=prod --labels='!seed'
```

*What just happened:* you asked for the prod context and explicitly excluded anything labelled `seed`. Contexts and labels both support boolean expressions (`and`, `or`, `!`), which is how teams carve one changelog into per-environment, per-feature deploys without forking files.

## The gotcha that bites everyone: editing applied changesets

Recall the rule from Phase 1: a changeset's identity is `id` + `author` + file, not its content. That rule has teeth. When Liquibase applies a changeset, it also stores a **checksum** of the content. On the next run, it recomputes the checksum and compares.

```text
$ liquibase update

Validation Failed:
     1 changesets check sum
          db/changelog.yaml::10::alice was: 8:a1b2c3... but is now: 8:d4e5f6...
```

*What just happened:* you edited the SQL of changeset 10 *after* it had already run somewhere. The checksum no longer matches the one in the ledger, and Liquibase halts to protect you, the database has the old version of that change, but the file now says something different, and Liquibase refuses to guess which is correct. The fix is almost never to "make the error go away"; it is to add a *new* changeset that alters the schema the way you now want. Treat applied changesets as immutable history.

> If you genuinely need to change an applied changeset's text without changing the database (a typo in a comment, a reformat), `liquibase clear-checksums` makes Liquibase recompute checksums on the next run. Reach for it knowingly, not as a reflex to silence an error, the error usually means your file and your database have actually diverged.

## Preconditions: refuse to run on the wrong database

Liquibase can guard a changeset (or the whole changelog) with a **precondition**, a check that must hold or Liquibase stops. This is how you keep a migration from running against a database it was not meant for.

```yaml
  - changeSet:
      id: 40
      author: alice
      preConditions:
        - onFail: HALT
        - dbms:
            type: postgresql
      changes:
        - sql:
            sql: CREATE INDEX CONCURRENTLY idx_book_title ON book (title)
```

*What just happened:* `CREATE INDEX CONCURRENTLY` is PostgreSQL-specific, so the precondition refuses to run this changeset on any other engine, with `onFail: HALT` stopping the whole update rather than risking a broken index elsewhere. Preconditions turn "this assumes Postgres" from a comment into an enforced contract.

## Where the wheels come off

A short field guide to the failure modes that actually page people:

- **The lock that never released.** If a Liquibase run is killed mid-flight (a crashed CI job, a `kill -9`), the `DATABASECHANGELOGLOCK` row can stay set, and the next run hangs waiting for a lock no one holds. `liquibase release-locks` clears it. Verify no other run is actually live first.
- **Two changesets, same id and author.** Liquibase identifies by `id` + `author` + file. Reuse a pair within a file and behavior gets confusing fast. Keep ids unique per author per file; many teams use sequential numbers or a ticket id.
- **Long-running changes holding a transaction.** A big data backfill inside one changeset can lock tables for the duration. Split large backfills, or run them outside the migration path entirely.
- **`CONCURRENTLY` inside a transaction.** PostgreSQL forbids `CREATE INDEX CONCURRENTLY` in a transaction, but Liquibase wraps changesets in one by default. Set `runInTransaction: false` on that changeset.

In the wild: most Liquibase incidents are not Liquibase bugs, they are someone editing applied history or a lock left behind by a dead process. Both are prevented by discipline, append-only changelogs and clean shutdowns, far more than by any flag. If you came here from [how an ORM works](/guides/how-an-orm-works), note that ORM auto-migration tools share these same hazards; Liquibase is merely explicit about them.

```quiz
[
  {
    "q": "When does Liquibase's database-agnostic change types pay off most?",
    "choices": [
      "When you target a single database engine",
      "When you target more than one engine from one changelog",
      "When you only ever write raw SQL",
      "When you never roll back"
    ],
    "answer": 1,
    "explain": "Portable change types translate to each engine's dialect. With one target engine, they add ceremony without benefit."
  },
  {
    "q": "What is the difference between contexts and labels?",
    "choices": [
      "Contexts describe where a changeset runs; labels categorize what it is",
      "They are identical aliases",
      "Labels run first, contexts run second",
      "Contexts are for rollback only"
    ],
    "answer": 0,
    "explain": "Contexts are environment-ish (where it runs); labels are a categorization you filter on at run time. Both support boolean expressions."
  },
  {
    "q": "Liquibase reports a checksum mismatch on an applied changeset. What is the right fix?",
    "choices": [
      "Always run clear-checksums to silence it",
      "Delete the changeset from the file",
      "Add a new changeset for the change you actually want; treat applied ones as immutable",
      "Re-run update with --force"
    ],
    "answer": 2,
    "explain": "A mismatch usually means the file and the database diverged. Append a new changeset rather than rewriting applied history."
  }
]
```
