Table of Contents
Let’s face it, most of us aren’t lucky enough to start a project from scratch, where we can follow all the best practices for database migrations and schema versioning. And if you’ve relied on Hibernate’s ability to auto-detect schema changes and update the database, you’ve probably been led to believe it’s got you covered.
But here’s the hard truth: the Hibernate team advises against using auto DDL in production 😳.
We’ve all seen developers trying to use Hibernate’s SchemaUpdate feature to automatically update production databases. Spoiler alert: it rarely ends well, and any experienced DBA would block it. The problem is that Hibernate (and many other diff-based tools) can’t always detect semantic changes. For example, if you rename an entity from X to Y, Hibernate doesn’t treat it as a simple rename. Instead, it sees two separate actions: dropping entity X and creating a new entity Y. Combine that with auto DDL, and you might as well prepare for chaos ⚔️.
In this tutorial, I’ll show you how to properly manage database migrations in a live application using a reliable migration tool. By the end, you’ll have a solid grasp of database versioning—and yes, you’ll be able to confidently use git blame
to trace your changes (use it wisely 👀).
Why choose Liquibase?
Liquibase stands out as the go-to Java library for version-based database migrations. It doesn’t just make your life easier—it brings a ton of benefits to the table:
- It supports a wide range of relational databases like MySQL, MariaDB, PostgreSQL, Oracle, and more.
- Liquibase ensures that all required updates are executed in sequence to always bring your database to the latest version.
- It keeps your database and application changes in sync, so you can deploy both together seamlessly.
- If something goes wrong during an update, Liquibase automatically generates and executes rollback operations to save the day.
- Its update operations are database-independent, meaning you won’t need to rewrite your logic for different databases.
- You can describe changes using SQL, XML, YAML, or JSON—whichever works best for you.
- Liquibase’s extensive support for plugins and extensions means it can be tailored to fit your needs.
- It also uses a distributed locking system, so only one process can modify the database at a time, preventing conflicts and ensuring consistency.
With all of these features, Liquibase doesn’t just manage your database migrations—it makes them smarter, safer, and more efficient.
Let’s Apply This.
In this section, I’ll show you how to set up a small app locally, which I’ll be treating as my “in-production” app. But hey, if you’re already comfortable with this stuff, feel free to skip ahead.
No worries, Einstein 🤓, I’ve got you covered! All you need to do is clone this repository and choose your path:
Using the Dockerized version…
If you’re one of the cool kids rocking Docker, just grab your wand and repeat after me:docker-compose up --build
What does this magic spell do? It pulls the MariaDB and OpenJDK images, builds our app’s image based on the OpenJDK one, and creates two containers: liquibasemigration
and database
. Then, it starts both containers. Voilà! You’ve got two services up and running.
By default, a database named todosdb
will be created automatically for us. If you’d like to change that, just tweak the docker-compose.yml
file. Here’s the part you’ll want to look at:
...
services:
...
db:
image: mariadb
container_name: database
environment:
MYSQL_ROOT_PASSWORD: root
# This will create a database named todosdb
MYSQL_DATABASE: todosdb
...
Feel free to adjust the database name or settings to whatever fits your project.
Without Docker
Don’t have Docker? No problem 😊. Although, you should consider getting it—it’s awesome! Still not convinced? 😞 No worries, you can set it up manually. All you need is a MariaDB database. Just update the application.properties
file with your database credentials, and hit run.
Once the app starts, it will automatically seed the database with three todos. Want to change the default entries? You can easily customize them in the code. Here’s the part responsible for that:
public class LiquibaseMigrationsApplication implements CommandLineRunner {
private final TodoRepository todoRepository;
@Override
public void run(String... args) throws Exception {
// This code seeds the database with 3 entries, but only if no todos exist
log.info("Seeding the Database");
if (todoRepository.count() == 0) {
List<Todo> todos = Arrays.asList(
"Make Nimbleways great again",
"PS5 gameplay, it's happening soon right Abbes?",
"Have breakfast of course :p"
).stream()
.map(content -> {
Todo todo = new Todo();
todo.setContent(content);
return todo;
})
.collect(Collectors.toList());
todoRepository.saveAll(todos);
log.info("Seeded the DB with {} todos", todos.size());
}
}
}
This simple snippet adds three todos to your database—unless you already have some entries, of course! Feel free to customize the todos to whatever suits your project.
“Where should I start?”
To kick things off, you’ll need to set up Liquibase. There are several ways to install it—Liquibase CLI, a Docker image, or even manual installation if you’re feeling adventurous. But since you’re likely using Maven in your Spring Boot project, we’ll keep it simple and use the Liquibase Maven plugin.
Here’s what you need to do:
- Open your
pom.xml
file. - Add the Liquibase core dependency and the Maven plugin.
Don’t forget to include the necessary properties:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<java.version>1.8</java.version>
<!-- Specify where the liquibase properties file will live -->
<liquibase.propertyFile>src/main/resources/liquibase/liquibase.properties</liquibase.propertyFile>
<!-- Set the version of Liquibase -->
<liquibase.version>4.2.2</liquibase.version>
</properties>
<dependencies>
<!-- Add Liquibase core dependency -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>${liquibase.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Add Liquibase Maven plugin -->
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>${liquibase.version}</version>
<configuration>
<!-- Point to the liquibase.properties file for configuration -->
<propertyFileWillOverride>true</propertyFileWillOverride>
<propertyFile>${liquibase.propertyFile}</propertyFile>
</configuration>
<dependencies>
<!-- Ensure Spring dependencies are available -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${project.parent.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>
Next, create a liquibase.properties
file in src/main/resources/liquibase
. Don’t forget to create the liquibase
folder if it doesn’t already exist.
And that’s it! You’re now set up to use Liquibase with your Spring Boot app.
The liquibase.properties
file
This file helps configure Liquibase, and it’s especially useful for setting up a testing database before making changes to your production database. Here’s what your file should include:
# Set up your testing database before applying changes to your production database
url: jdbc:mariadb://localhost:13306/todosdb
username: root
password: root
driver: org.mariadb.jdbc.Driver
# This is the main file where all the DB migrations will be defined
changeLogFile: src/main/resources/liquibase/db.changelog-master.xml
# This file will be used later to generate changelogs from the current DB state
outputChangeLogFile: src/main/resources/liquibase/db.changelog-generated.xml
# Optional: Enable verbose output for more detailed logs
verbose: true
That’s it! Liquibase is now all set up and ready to use. But before we dive deeper, let’s quickly go over some key terms in Liquibase.
Liquibase Key Terms
- Changelog: A file that stores database changes that have been applied or are waiting to be applied.
- Changeset: The smallest unit of change, identified by an ID and author. It could be anything from creating a table to adding or removing columns.
- DATABASECHANGELOG: A table created by Liquibase to track which changesets have been applied, preventing them from running multiple times.
Back to our project
Our goal is to integrate Liquibase into an existing project, which means we already have a database. The first step is to create a changelog based on the current state of the database, as if Liquibase had been managing it all along. That’s where the outputChangeLogFile
property comes in.
To generate the changelog, make sure your database is up and running, then execute the following command:
mvn liquibase:generateChangeLog
Once the command finishes, Liquibase will generate the outputChangeLogFile
with content similar to this:
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<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-4.1.xsd">
<!-- The id and author must always be unique. These are generated for you. -->
<changeSet author="mac (generated)" id="1607904245529-1">
<createTable tableName="todo">
<!-- A description of all the columns in our todo table -->
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="completed" type="BIT">
<constraints nullable="false"/>
</column>
<column name="content" type="VARCHAR(255)"/>
<column defaultValueComputed="NULL" name="created_at" type="datetime"/>
<column defaultValueComputed="NULL" name="updated_at" type="datetime"/>
</createTable>
</changeSet>
</databaseChangeLog>
Depending on the size of your database, you might end up with a lot of changesets. The first thing to do is to check that all the changesets match your database schema—pay special attention to default values. If everything looks good, great! You’re ready to move on to the next step.
Syncing Your Generated Changelog
Once you’ve reviewed and confirmed that all the changesets are correct (and fixed any issues along the way), you’re ready to sync your changelog with the database. What does that mean? Liquibase will create a table in your database called DATABASECHANGELOG
and populate it with information, marking all the changesets as “executed” so they won’t run again.
Start by creating a new file called db.changelog-master.xml
inside your Liquibase folder. You can name this file anything you like, but remember to update your liquibase.properties
to reflect the name you choose. I’m sticking with a more conventional naming pattern here since it’s generally considered a best practice.
The db.changelog-master.xml
file acts as the entry point for all the other changelogs. It won’t contain actual changes—just a set of include
statements that point to other changelog files.
Here’s how it might look:
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
xmlns:pro="http://www.liquibase.org/xml/ns/pro"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.liquibase.org/xml/ns/dbchangelog-ext
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd
http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-4.1.xsd
http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Since we only have one changelog at the moment, we’ll include it here -->
<include file="db.changelog-0.0.1-SNAPSHOT.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
Following this structure, you’ll have one master changelog file (db.changelog-master.xml
) that references all other changelogs. Each non-master changelog will contain all the database changes specific to that release. So, the number of changelogs will match the number of releases + 1, and version control will handle the versioning.
Next, rename the generated changelog file to reflect the current release version. In my case, it’ll be db.changelog-0.0.1-SNAPSHOT.xml
.
Once you’re ready, run the command mvn liquibase:changelogSync
. After that, if you check your database, you’ll notice two new tables have been created: DATABASECHANGELOG
and DATABASECHANGELOGLOCK
.
Run the App
Well, yes… but not quite. There are still a couple of important steps before you’re good to go. In your src/main/resources/application.properties
file, you need to:
- Change Hibernate’s auto-DDL setting from
update
tovalidate
. - Disable Liquibase from running automatically at startup.
Here’s how that should look:
spring.jpa.hibernate.ddl-auto=validate
spring.liquibase.enabled=false
By default, Spring Boot will automatically run Liquibase when it detects it in the classpath. This can result in database changes being applied right away—and not all of those changes are reversible!
The proper way to handle this is to first check the SQL that Liquibase will execute. You can do this by running the command mvn liquibase:updateSQL
. Once you’ve reviewed the SQL and confirmed it’s good to go, apply the update with mvn liquibase:update
.
Conclusion
In this guide, we’ve explored how to migrate a database using Liquibase, a powerful tool for managing database changes effectively. By setting up Liquibase in your Spring Boot application, you can easily create and manage changelogs, ensuring your database schema evolves smoothly without the risk of errors.
From syncing your changelog to configuring Hibernate and Liquibase settings, each step is crucial for maintaining the integrity of your database during migrations. By following best practices and carefully reviewing SQL changes before applying them, you can ensure a seamless migration process that keeps your application running safely and efficiently.
With Liquibase, you’re well-equipped to handle database migrations with confidence. Happy migrating!