One-Stop Guide to Database Migration with Liquibase and Spring Boot

Table Of Contents

Spring Boot provides integration with database migration tools Liquibase and Flyway. This guide provides an overview of Liquibase and how to use it in a Spring Boot application for managing and applying database schema changes.

Example Code

This article is accompanied by a working code example on GitHub.

Why Do We Need Database Migration Tools?

Database migration tools help us to track, version control, and automate database schema changes. They help us to have a consistent schema across different environments.

Refer to our guides for more details on why we need database migration tools and for a quick comparison of Liquibase and Flyway.

Introduction to Liquibase

Liquibase facilitates database migrations with not only plain old SQL scripts, but also with different abstract, database-agnostic formats including XML, YAML, and JSON. When we use non-SQL formats for database migrations, Liquibase generates the database-specific SQL for us. It takes care of variations in data types and SQL syntax for different databases. It supports most of the popular relational databases.

Liquibase allows enhancements for databases it currently supports through Liquibase extensions. These extensions can be used to add support for additional databases as well.

Core Concepts of Liquibase

Let’s have a look at the vocabulary of Liquibase:

  • ChangeSet: A changeSet is a set of changes that need to be applied to a database. Liquibase tracks the execution of changes at a ChangeSet level.

  • Change: A change describes a single change that needs to be applied to the database. Liquibase provides several change types like “create table” or “drop column” out of the box which are each an abstraction over a piece of SQL.

  • Changelog: The file which has the list of database changeSets that needs to be applied is called a changelog. These changelog files can be in either SQL, YAML, XML, or JSON format.

  • Preconditions: Preconditions are used to control the execution of changelogs or changeSets. They are used to define the state of the database under which the changeSets or changes logs need to be executed.

  • Context: A changeSet can be tagged with a context expression. Liquibase will evaluate this expression to determine if a changeSet should be executed at runtime, given a specific context. You could compare a context expression with environment variables.

  • Labels: The purpose of Labels is similar to that of contexts. The difference is that changeSets are tagged with a list of labels (not expressions), and during runtime, we can pass a label expression to choose the changeSets which match the expression.

  • Changelog Parameters: Liquibase allows us to have placeholders in changelogs, which it dynamically substitutes during runtime.

Liquibase creates the two tables databasechangelog and databasechangeloglock when it runs in a database for the first time. It uses the databasechangelog table to keep track of the status of the execution of changeSets and databasechangeloglock to prevent concurrent executions of Liquibase. Refer to the docs for more details.

Liquibase with Spring Boot

Now that we went through the basics of Liquibase let’s see how to get Liquibase running in a Spring Boot application.

Setting Up Liquibase in Spring Boot

By default Spring Boot auto-configures Liquibase when we add the Liquibase dependency to our build file.

Spring Boot uses the primary DataSource to run Liquibase (i.e. the one annotated with @Primary if there is more than one). In case we need to use a different DataSource we can mark that bean as @LiquibaseDataSource.

Alternatively, we can set the spring.liquibase.[url,user,password]properties, so that spring creates a Datasource on its own and uses it to auto-configure Liquibase.

By default, Spring Boot runs Liquibase database migrations automatically on application startup.

It looks for a master changelog file in the folder db/migration within the classpath with the name db.changelog-master.yaml. If we want to use other Liquibase changelog formats or use different file naming convention, we can configure the spring.liquibase.change-log application property to point to a different master changelog file.

For example, to use db/migration/my-master-change-log.json as the master changelog file, we set the following property in application.yml:

spring:
  liquibase:
    changeLog: "classpath:db/migration/my-master-change-log.json"

The master changelog can include other changelogs so that we can split our changes up in logical steps.

Running Our First Database Migration

After setting everything up, let’s create our first database migration. We’ll create the database table user_details in this example.

Let’s create a file with name db.changelog-master.yaml and place it in src/main/resources/db/changelog:

databaseChangeLog:
  - include:
      file: db/changelog/db.changelog-yaml-example.yaml

The master file is just a collection of includes that points to changelogs with the actual changes.

Next, we create the changelog with the first actual changeset and put it into the file src/main/resources/db/changelog-yaml-example.yaml:

databaseChangeLog:
  - changeSet:
      id: create-table-user
      authors: [liquibase-demo-service]
      preConditions:
        - onFail: MARK_RAN
          not:
            tableExists:
              tableName: user_details
      changes:
        - createTable:
            columns:
              - column:
                  autoIncrement: true
                  constraints:
                    nullable: false
                    primaryKey: true
                    primaryKeyName: user_pkey
                  name: id
                  type: BIGINT
              - column:
                  constraints:
                    nullable: false
                  name: username
                  type: VARCHAR(250)
              - column:
                  constraints:
                    nullable: false
                  name: first_name
                  type: VARCHAR(250)
              - column:
                  name: last_name
                  type: VARCHAR(250)
            tableName: user_details

We used the changeType createTable, which abstracts the creation of a table. Liquibase will convert the above changeSet to the appropriate SQL based on the database that our application uses.

The preCondition checks that the user_details table does not exist before executing this change. If the table already exists, Liquibase marks the changeSet as having run successfully without actually having run.

Now, when we run the Spring Boot application, Liquibase executes the changeSet which creates the user_details table with user_pkey as the primary key.

Using Changelog Parameters

Changelog parameters come in very handy when we want to replace placeholders with different values for different environments. We can set these parameters using the application property spring.liquibase.parameters, which takes a map of key/value pairs:

spring:
  profiles: docker
  liquibase:
    parameters:
      textColumnType: TEXT
    contexts: local
---
spring:
  profiles: h2
  liquibase:
    parameters:
      textColumnType: VARCHAR(250)
    contexts: local    

We set the Liquibase parameter textColumnType to VARCHAR(250) when Spring Boot starts in the h2 profile and to TEXT when it starts in the docker profile (assuming that the docker profile starts up a “real” database).

We can now use this parameter in a changelog:

databaseChangeLog:
  - changeSet:
     ...
      changes:
        - createTable:
            columns:
             ...
              - column:
                  constraints:
                    nullable: false
                  name: username
                  type: ${textColumnType}

Now, when the Spring Boot application runs in the docker profile, it uses TEXT as column type and in the h2 profile it uses VARCHAR(250).

Use the Same Database for All Environments!

The code example assumes the usage of different types of databases in different environments for demonstrating the use of the changelog parameter. Please avoid using different types of databases for different staging environments. Doing so will cause hard-to-debug errors caused by different environments.

Using Liquibase Context

As described earlier, context can be used to control which changeSets should run. Let’s use this to add test data in the test and local environments:

<databaseChangeLog>
 <changeSet 
   author="liquibase-docs" 
   id="loadUpdateData-example" 
   context="test or local">
   <loadUpdateData
     encoding="UTF-8"
     file="db/data/users.csv"
     onlyUpdate="false"
     primaryKey="id"
     quotchar="'"
     separator=","
     tableName="user_details">
   </loadUpdateData>
 </changeSet>
</databaseChangeLog>

We’re using the expression test or local so it runs for these contexts, but not in production.

We now need to pass the context to Liquibase using the property spring.liquibase.contexts:

---
spring:
  profiles: docker
  liquibase:
    parameters:
      textColumnType: TEXT
    contexts: test

Configuring Liquibase in Spring Boot

As a reference, here’s a list of all properties that Spring Boot provides to configure the behavior of Liquibase.

Property Description
spring.liquibase.changeLog Master changelog configuration path. Defaults to classpath:/db/changelog/db.changelog-master.yaml,
spring.liquibase.contexts Comma-separated list of runtime contexts to use.
spring.liquibase.defaultSchema Schema to use for managed database objects and Liquibase control tables.
spring.liquibase.liquibaseSchema Schema for Liquibase control tables.
spring.liquibase.liquibaseTablespace Tablespace to use for Liquibase objects.
spring.liquibase.databaseChangeLogTable To specify a different table to use for tracking change history. Default is DATABASECHANGELOG.
spring.liquibase.databaseChangeLogLockTable To specify a different table to use for tracking concurrent Liquibase usage. Default is DATABASECHANGELOGLOCK.
spring.liquibase.dropFirst Indicates whether to drop the database schema before running the migration. Do not use this in production! Default is false.
spring.liquibase.user Login username to connect to the database.
spring.liquibase.password Login password to connect to the database.
spring.liquibase.url JDBC URL of the database to migrate. If not set, the primary configured data source is used.
spring.liquibase.labels Label expression to be used when running liquibase.
spring.liquibase.parameters Parameters map to be passed to Liquibase.
spring.liquibase.rollbackFile File to which rollback SQL is written when an update is performed.
spring.liquibase.testRollbackOnUpdate Whether rollback should be tested before the update is performed. Default is false.

Enabling Logging for Liquibase in Spring Boot

Enabling INFO level logging for Liquibase will help to see the changeSets that Liquibase executes during the start of the application. It also helps to identify that the application has not started yet because it is waiting to acquire changeloglock during the startup.

Add the following application property in application.yml to enable INFO logs:

logging:
  level:
    "liquibase" : info

Best Practices Using Liquibase

  • Organizing Changelogs: Create a master changelog file that does not have actual changeSets but includes other changelogs (only YAML, JSON, and XML support using include, SQL does not). Doing so allows us to organize our changeSets in different changelog files. Every time we add a new feature to the application that requires a database change, we can create a new changelog file, add it to version control, and include it in the master changelog.

  • One Change per ChangeSet: Have only one change per changeSet, as this allows easier rollback in case of a failure in applying the changeSet.

  • Don’t Modify a ChangeSet: Never modify a changeSet once it has been executed. Instead, add a new changeSet if modifications are needed for the change that has been applied by an existing changeSet. Liquibase keeps track of the checksums of the changeSets that it already executed. If an already run changeSet is modified, Liquibase by default will fail to run that changeSet again, and it will not proceed with the execution of other changeSets.

  • ChangeSet Id: Liquibase allows us to have a descriptive name for changeSets. Prefer using a unique descriptive name as the changeSetId instead of using a sequence number. They enable multiple developers to add different changeSets without worrying about the next sequence number they need to select for the changeSetId.

  • Reference data management: Use Liquibase to populate reference data and code tables that the application needs. Doing so allows deploying application and configuration data it needs together. Liquibase provides changeType loadUpdateData to support this.

  • Use Preconditions: Have preconditions for changeSets. They ensure that Liquibase checks the database state before applying the changes.

  • Test Migrations: Make sure you always test the migrations that you have written locally before applying them in real nonproduction or production environment. Always use Liquibase to run database migrations in nonproduction or production environment instead of manually performing database changes.

Running Liquibase automatically during the Spring Boot application startup makes it easy to ship application code changes and database changes together. But in instances like adding indexes to existing database tables with lots of data, the application might take a longer time to start. One option is to pre-release the database migrations (releasing database changes ahead of code that needs it) and run them asynchronously.

Other Ways of Running Liquibase

Liquibase supports a range of other options to run database migrations apart from Spring Boot integration:

Liquibase has a Java API that we can use in any Java-based application to perform database migrations.

Conclusion

Liquibase helps to automate database migrations, and Spring Boot makes it easier to use Liquibase. This guide provided details on how to use Liquibase in Spring Boot application and some best practices.

You can find the example code on GitHub.

We also have a guide on using Flyway, another popular alternative for database migrations.

Written By:

Prabhakar Digumarthi

Written By:

Prabhakar Digumarthi

An experienced and skilled software engineer with a strong fervor to design and develop scalable, resilient, and error-free software. A staunch believer in knowledge exchange and professional collaboration. Willing to share my expertise in building java and spring based applications while learning from others.

Recent Posts

Guide to JUnit 5 Functional Interfaces

In this article, we will get familiar with JUnit 5 functional interfaces. JUnit 5 significantly advanced from its predecessors. Features like functional interfaces can greatly simplify our work once we grasp their functionality.

Read more

Getting Started with Spring Security and JWT

Spring Security provides a comprehensive set of security features for Java applications, covering authentication, authorization, session management, and protection against common security threats such as CSRF (Cross-Site Request Forgery).

Read more

Creating and Publishing an NPM Package with Automated Versioning and Deployment

In this step-by-step guide, we’ll create, publish, and manage an NPM package using TypeScript for better code readability and scalability. We’ll write test cases with Jest and automate our NPM package versioning and publishing process using Changesets and GitHub Actions.

Read more