Introducing Liquibase: How to version control your DB
Databases can time travel too
Introducing Liquibase: How to version control your DB
Databases can time travel too

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:
- Reads the changelog files
- Compares them against a tracking table inside your database
- 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
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:
--liquibase formatted sql: This tells Liquibase to parse the file using its change tracking system.--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.- The
rollbackflag: 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:
- Liquibase checks the database
- It compares executed changesets against its internal tracking table
- 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

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:
- Tables are created
- Columns are added
- Data is inserted
- 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_humancolumn 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:
- Define their change sets (SQL, XML, YAML, or JSON)
- Commit them to a Git repository
- 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