← Back to list

Introducing Liquibase: How to version control your DB

Databases can time travel too

Tituslhy in MITB For All · 2026-02-20 03:30 · 70 claps · 10.8 min read
#liquibase #spring-boot #postgresql #database #version-control
Open on Medium ↗
Wiki topics: ✈️ · Travel

Introducing Liquibase: How to version control your DB

Databases can time travel too

Time travelling with postgresql. Image generated by nano banana

Time travelling with postgresql. Image generated by nano banana

Stop me if you’ve heard this one before.

It was supposed to be harmless.

One ALTER TABLE.

One deploy.

Five minutes.

Instead, production was down.

The migration script ran.

The table didn’t exist.

A stored procedure referenced the wrong schema.

No one was entirely sure which version of the database production was actually on. For months, schema changes had been applied manually — ALTER TABLE, DROP COLUMN, quick fixes in staging, hotfixes in production.

We treat application deployments like reversible time travel.

Helm rolls back.

Kubernetes restores pods.

Git rewinds commits.

But your database? That thing only moves forward — unless you’ve engineered time travel into it.

🧐 Couldn’t you just be more careful?

It’s tempting to blame carelessness.

But in my opinion, the real issue isn’t discipline. It’s system design.

Production databases are rarely owned by a single team. Multiple applications depend on the same tables. A small “harmless” schema tweak for one service may quietly break another.

No engineer has perfect visibility across every stored procedure, foreign key, trigger, or dependent service.

Under those conditions, failure isn’t negligence. It’s inevitability.

A quick clarification on “DB”?

In this article, when I say “DB”, I’m referring specifically to OLTP (Online Transaction Processing) databases.

These are the databases that power live applications:

  • User logins
  • Booking systems
  • Checkout carts
  • Financial transactions

Data is typically stored in rows.

OLTP DBs are optimized for small, precise reads and writes, handle high concurrency and enforce strong ACID guarantees.

Common examples include PostgreSQL, Microsoft SQL Server, Oracle Database, MySQL, etc. These are the databases most developers interact with when building software.

What about other types of databases?

There are other systems designed for different workloads.

OLAP (Online Analytical Processing) databases store data in columnar formats and are optimized for large-scale aggregations and analytical queries.

They are built to scan massive datasets efficiently rather than handle high-frequency transactional updates.

Examples include ClickHouse, Snowflake, BigQuery, Amazon Redshift, etc.

Then there are lakehouse architectures, which combine elements of data lakes and data warehouses.

Lakehouses store large datasets — often in columnar formats like Parquet — in object storage (e.g., Amazon S3) and organize them into table abstractions.

Tools such as:

  • Apache Iceberg
  • Delta Lake
  • Apache Hudi

add features like schema evolution and snapshot-based time travel.

In a lakehouse, you can do something like:

SELECT * FROM table VERSION AS OF 123;

And the system will query the data as it existed at snapshot 123.

That’s built-in time travel.

OLTP databases don’t give you that luxury. They are not Iceberged. And yet, they are the systems most directly tied to production applications. They hold the data your customers interact with in real time.

Introducing Liquibase

Simply put, Liquibase is a Java-based command-line tool that manages database schema changes in a structured, version-controlled way.

Instead of running ad-hoc ALTER TABLE statements directly against your database, developers declare schema changes in structured files — SQL, XML, YAML, or JSON.

These files (called changelogs) are committed to Git.

Liquibase then:

  1. Reads the changelog files
  2. Compares them against a tracking table inside your database
  3. Executes only the changes that have not yet been applied

In other words, your database schema evolves the same way your application code does — through versioned, reviewable changes.

Even more importantly, Liquibase supports controlled rollbacks.

You can instruct it to revert the database to a specific tag or state, effectively introducing controlled “time travel” into systems that normally only move forward.

The best part? Liquibase supports all OLTP and OLAP database types above — yes even Snowflake, Terradata, Databricks, CockroachDB, etc. Liquibase confers time travel capabilities for all!

In this article, we’ll focus on OLTP databases — and how tools like Liquibase allow us to bring version control, structure, and safe evolution to systems that were never designed with rewind buttons.

All my code is available in my GitHub repo.

Installing Liquibase

To get started, install the Liquibase community edition from their official website. If you’re a Mac user like me, the command is simply:

brew install liquibase

Liquibase requires a Java runtime.

If you’re using the official installer, Java is bundled. Otherwise, verify that Java is installed:

java -version

Once installation is complete, confirm everything works:

liquibase -version

Spinning up our DB

Let’s create a local PostgreSQL instance for testing.

First, create a .env file:

export DB_USERNAME=...
export DB_PASSWORD=...

Load the variables into your current shell session:

source .env

Now spin up PostgreSQL using Docker:

docker run --name postgres-local \
  -e POSTGRES_USER=${DB_USERNAME} \
  -e POSTGRES_PASSWORD=${DB_PASSWORD} \
  -e POSTGRES_DB=postgres \
  -p 5432:5432 \
  -v pgdata:/var/lib/postgresql/data \
  -d postgres:16

This gives us a clean, isolated OLTP database to experiment with.

Creating a Java Project

Technically, you don’t need a Java application to use Liquibase.

You can define a liquibase.properties file and run everything directly from the CLI.

However, in real production environments, schema migrations are usually executed as part of an application startup or CI/CD pipeline. Nobody SSHs into a production server and types ALTER TABLE by hand (at least… nobody should).

That’s why we’ll integrate Liquibase with Spring Boot — a common pattern in enterprise systems. This way, schema changes are applied automatically and consistently whenever the application starts.

Bootstrapping the Project

Head to the Spring Initializr website generate a new Spring boot project as follows:

Screenshot of my spring initializer page

Screenshot of my spring initializer page

Clicking “Generate” downloads a zip file with the code required. Once downloaded, unzip it and open the project in your preferred IDE (I use IntelliJ for Java projects).

Updating pom.xml

Add the following dependencies to your pom.xml:

  • spring-boot-starter
  • spring-boot-starter-liquibase
  • postgresql (runtime)
  • spring-boot-starter-test

The key dependency here is:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-liquibase</artifactId>
</dependency>

This ensures Liquibase runs automatically during application startup.

If you want to execute Liquibase from Maven directly, include the Liquibase Maven plugin (I did):

<plugin>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-maven-plugin</artifactId>
    <version>5.0.1</version>
    <configuration>
        <changeLogFile>src/main/resources/db/changelog/db.changelog-master.xml</changeLogFile>
        <url>jdbc:postgresql://localhost:5432/postgres</url>
        <username>postgres</username>
        <password>postgres</password>
        <driver>org.postgresql.Driver</driver>
    </configuration>
</plugin>

At this stage, we don’t need to modify any Java classes — so the code generated by the Spring initializer suffices. Our focus will be entirely on configuration and changelogs (src/main/resources ).

Configuring the Database Connection

In src/main/resources/application.properties, configure your datasource and Liquibase:

spring.application.name=java
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.liquibase.change-log=classpath:/db/changelog/db.changelog-master.xml
spring.liquibase.enabled=true

logging.level.root=INFO
logging.level.org.springframework=INFO
logging.level.liquibase=INFO
logging.level.liquibase.sql=DEBUG

This tells Spring Boot:

  • How to connect to PostgreSQL
  • Where your master changelog lives
  • That Liquibase should run automatically on startup

Now every time the application starts, Liquibase will inspect the database and apply any pending changes.

Creating the master changelog

Liquibase needs a master changelog file that acts as the entry point for all migrations. In your src/resources/db/changelog folder, create a db.changelog-master.xml file and add:

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

</databaseChangeLog>

Think of this file as the “table of contents” for your schema evolution.

Writing our first update

Liquibase allows migrations to be written in formatted SQL.

Inside the same db/changelog folder, create a 001-create-table.sql file:

--liquibase formatted sql

--changeset titus:1
CREATE TABLE liquibase (
    id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(50) NOT NULL
);

--rollback DROP TABLE liquibase;

There are three important elements here:

  1. --liquibase formatted sql : This tells Liquibase to parse the file using its change tracking system.
  2. --changeset <author>:<id> : This uniquely identifies the migration. Liquibase records every executed changeset in a special internal table called DATABASECHANGELOG. If a changeset has already run, it won’t run again. This is how Liquibase prevents duplicate execution.
  3. The rollback flag: This explicitly defines how to undo the migration. Rollback is optional — but highly recommended. Otherwise, Liquibase may attempt auto-generated rollback logic, which can be unpredictable for complex changes. Explicit rollback makes your schema evolution intentional and reversible.

Adding the SQL File to the Master Changelog

Now we need to register our SQL migration in the master changelog.

Update db.changelog-master.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">

    <include file="db/changelog/001-create-table.sql"/>

</databaseChangeLog>

The <include> tag tells Liquibase to execute the referenced migration as part of the overall changelog sequence.

Running the Migration

To execute the migration, run:

mvn spring-boot:run

This starts the Spring Boot application, which automatically triggers Liquibase during startup.

When the application initializes:

  1. Liquibase checks the database
  2. It compares executed changesets against its internal tracking table
  3. It runs any new migrations

If everything succeeds, you should see:

  • The liquibase table created
  • Two additional tables created by Liquibase itself

Understanding Liquibase’s Internal Tables

Liquibase creates two important tables:

1️⃣ DATABASECHANGELOG

This table records:

  • Changeset ID
  • Author
  • Filename
  • Execution timestamp
  • Checksum

This is how Liquibase knows which migrations have already run.

If a changeset is recorded here, it will not be executed again.

This is the heart of Liquibase’s version control mechanism.

2️⃣ DATABASECHANGELOGLOCK

This table prevents race conditions.

Before running migrations, Liquibase acquires a lock by updating this table. If another Liquibase process attempts to run simultaneously, it will wait until the lock is released.

This guarantees that:

  • Only one migration process modifies the schema at a time
  • Parallel deployments don’t corrupt your database

In distributed systems, this safeguard is critical.

Writing updates using non-SQL methods

Liquibase doesn’t require raw SQL. You can also define migrations declaratively using XML, YAML, or JSON.

With formatted SQL, you write the exact DDL yourself. With non-SQL methods, you declare intent:

  • “Add a column”
  • “Drop a table”
  • “Create an index”

Liquibase then generates the correct SQL for your target database. This becomes powerful in multi-database environments — what works in Microsoft SQL Server may not work in PostgreSQL. Column types, identity syntax, and index definitions differ across dialects.

Using non-SQL methods lets Liquibase abstract those differences away. Instead of managing dialect quirks manually, you let Liquibase handle them.

Let’s try all these approaches.

The XML Way

Create a new xml file titled 002-add-column.xml and add:

<?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-5.0.xsd">

<changeSet id="2" author="titus">
    <addColumn tableName="liquibase">
        <column name="Vocation" type="varchar(255)"/>
    </addColumn>
    <rollback>
        <dropColumn tableName="liquibase" columnName="Vocation"/>
    </rollback>
</changeSet>

</databaseChangeLog>

This time you don’t have to write the actual SQL script out — just declare what you want Liquibase to do in angle brackets. Here we are adding a column to the database named “Vocation”.

The YAML way

Create a new yaml file titled 003-insert-record.yaml and add:

databaseChangeLog:
  - changeSet:
      id: 3
      author: titus
      changes:
        - insert:
            tableName: liquibase
            columns:
              - column:
                  name: id
                  valueNumeric: 1
              - column:
                  name: name
                  value: Titus
              - column:
                  name: Vocation
                  value: Data Scientist
        - insert:
            tableName: liquibase
            columns:
              - column:
                  name: id
                  valueNumeric: 2
              - column:
                  name: name
                  value: Claude
              - column:
                  name: Vocation
                  value: Agent
      rollback:
        - delete:
            tableName: liquibase
            where: id IN (1,2)

The YAML approach lengthens the file — everything must be now specified as a key-value pair. In this case, we are inserting seed data into the DB — a row for data scientist named Titus, and a row for an agent named Claude.

And finally, the JSON Way

Create a new json file titled 004-insert-species.json and add:

{
  "databaseChangeLog": [
    {
      "changeSet": {
        "id": "4",
        "author": "titus",
        "changes": [
          {
            "addColumn": {
              "tableName": "liquibase",
              "columns": [
                {
                  "column": {
                    "name": "is_human",
                    "type": "BOOLEAN"
                  }
                }
              ]
            }
          }
        ],
        "rollback": [
          {
            "dropColumn": {
              "tableName": "liquibase",
              "columnName": "is_human"
            }
          }
        ]
      }
    }
  ]
}

This creates adds boolean column is_human to your DB schema.

Include these files into the main changelog

Your main change log should now look like:

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

    <include file="db/changelog/001-create-table.sql"/>
    <include file="db/changelog/002-add-column.xml"/>
    <include file="db/changelog/003-insert-record.yaml"/>
    <include file="db/changelog/004-insert-species.json"/>

</databaseChangeLog>

Now spin the app up again with mvn spring-boot:run .

If you were to check your Postgres DB, you’ll see that the migrations were successful:

We have all the columns specified here

We have all the columns specified here

The changelog table shows that all four changeset files have been executed

The changelog table shows that all four changeset files have been executed

Ordering Matters

Liquibase executes changesets sequentially in the order they appear in the master changelog.

If you attempt to insert data into a column that hasn’t been created yet, the migration will fail. That’s not a Liquibase limitation — it’s intentional. Schema evolution must be deterministic.

Correct ordering ensures:

  1. Tables are created
  2. Columns are added
  3. Data is inserted
  4. Constraints are enforced

So if you had placed re-ordered the include tags for 002-add-column.xml and 003-insert-record.yaml , you’d have faced an error because the Vocation column hasn’t been created yet — but we were attempting to insert seed records that included vocations.

🍥 Rolling back

We’ll use the Liquibase CLI for rollbacks instead of Spring Boot — otherwise, we’d have to modify Java code.

Create a liquibase.properties file in the root folder:

# liquibase.properties
changeLogFile=src/main/resources/db/changelog/db.changelog-master.xml
url=jdbc:postgresql://localhost:5432/postgres
username=${DB_USERNAME}
password=${DB_PASSWORD}
driver=org.postgresql.Driver

Now we’re ready to roll back!

To undo the most recent change set:

mvn liquibase:rollback -Dliquibase.rollbackCount=1

Setting rollbackCount=2 reverts the last two changesets. After rolling back the final migration, you’ll notice:

  • The is_human column is gone
  • The corresponding entry has been removed from DATABASECHANGELOG

The database has returned to its previous state — cleanly, predictably, and without manual intervention.

Critical: Always test rollbacks in staging BEFORE production. A bad rollback script can be worse than the original migration.

No guessing.

No reverse-engineering SQL.

No scrambling in production.

This is database time travel.

Controlled. Traceable. Reversible.

🚀 How Does This Work in Production?

In a real production environment, Liquibase becomes part of your CI/CD workflow.

Developers don’t run SQL scripts directly against shared environments. Instead, they:

  1. Define their change sets (SQL, XML, YAML, or JSON)
  2. Commit them to a Git repository
  3. Submit a pull request

Every schema change is reviewed — just like application code.

The master change log file is updated through controlled merges. Once approved, the CI/CD pipeline executes the Liquibase commands during deployment, applying only the new, unexecuted changesets.

Liquibase integrates cleanly with tools like GitHub Actions and Apache Jenkins — even in the Community Edition — making automation straightforward. As an example, a GitHub Action script with Liquibase could include:

name: Liquibase CI
on:
  pull_request:
    types: [closed]
    branches:
      - master

jobs:
  build:
    runs-on: ubuntu-latest # Or windows-latest, macos-latest

    steps:
    - uses: actions/checkout@v4

    # Use the official setup-liquibase action
    - uses: liquibase/setup-liquibase@v2
      with:
        # Specify the Liquibase version (must be 4.32.0 or higher)
        version: '5.0.0'
        # Specify edition: 'community' or 'secure'
        edition: 'community' 

    # Run any Liquibase command using 'run'
    - run: liquibase --version
    - run: liquibase update --changelog-file=db.master-changelog.xml --url=jdbc:postgres://....
      env:
        # Set environment variables for secrets, e.g., database password
        DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

No manual execution.

No “run this script in prod quickly.”

No schema drift between environments.

🧠 Thinking about what we just did

In a few steps, we:

  • Version-controlled our database schema
  • Tracked every change inside the database itself
  • Prevented concurrent migration conflicts
  • Applied migrations automatically during application startup
  • Rolled back safely to a previous state

This is significantly safer than running ad-hoc SQL scripts against production databases.

It introduces engineering discipline into schema evolution — and makes database changes as traceable and reversible as application code.

All opinions and interpretations are that of the writer, and not of MITB. I declare that I have full rights to use the contents published here, and nothing is plagiarized. I declare that this article is written by me and not with any generative AI tool such as ChatGPT. I declare that no data privacy policy is breached, and that any data associated with the contents here are obtained legitimately to the best of my knowledge. I agree not to make any changes without first seeking the editors’ approval. Any violations may lead to this article being retracted from the publication.


메타데이터
post_id
d5a08f764c09
slug
introducing-liquibase-how-to-version-control-your-db-d5a08f764c09
url
https://medium.com/mitb-for-all/introducing-liquibase-how-to-version-control-your-db-d5a08f764c09
canonical_url
https://medium.com/mitb-for-all/introducing-liquibase-how-to-version-control-your-db-d5a08f764c09
author_url
https://medium.com/@tituslhy
status
ok
fetched_at
2026-07-13 06:23:13