Liquibase in a Spring Boot 4 application
First published on Habr (in Russian) on 22 July 2019; this is a rewritten and updated version.
In 2019 I wrote two articles on Habr about Liquibase in Spring Boot: the basics, then diffs and rollbacks with the Maven plugin. The ideas hold up; almost every version number and several configuration keys do not. This is both parts in one, rebuilt on Spring Boot 4.1, Liquibase 5.0 and Hibernate 7, and run end to end before writing. The project uses H2 in a file so it runs anywhere; the Liquibase side is the same for PostgreSQL or MySQL.
Why not ddl-auto=update
Most tutorials set spring.jpa.hibernate.ddl-auto=update and let Hibernate adjust the tables to the entities. It is convenient on day one and a liability later:
- you do not see or review the DDL Hibernate runs;
- it never drops or renames anything, so the schema drifts from the code;
- there is no rollback;
- data changes (filling a new column, renaming values) have nowhere to live.
Liquibase turns schema changes into files in the repository. Each changeset runs once per database; Liquibase records what it ran in a DATABASECHANGELOG table. Hibernate's job shrinks to checking that the tables and columns the entities need exist with compatible types:
spring.jpa.hibernate.ddl-auto=validate
With validate, a missing column fails the application at startup instead of the first query at runtime. It is not a full comparison: constraints such as NOT NULL or indexes are not checked.
The project
Spring Boot 4 split auto-configuration into modules, and Liquibase now has its own starter:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-liquibase</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
The 2019 article added liquibase-core by hand. With Boot 4, the starter is the way: it brings liquibase-core in the version Boot manages, plus the module that runs it at startup.
One entity:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false, unique = true, length = 64)
private String username;
@Column(name = "email", length = 255)
private String email;
// constructors and getters
}
The changelog
Spring Boot looks for db/changelog/db.changelog-master.yaml on the classpath by default. Keep the master file as a list of includes, one file per change:
databaseChangeLog:
- include:
file: changes/001-create-users.yaml
relativeToChangelogFile: true
databaseChangeLog:
- changeSet:
id: 001-create-users
author: usharik
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: username
type: varchar(64)
constraints:
nullable: false
unique: true
- column:
name: email
type: varchar(255)
Start the application and Liquibase runs before Hibernate validates:
Running Changeset: db/changelog/changes/001-create-users.yaml::001-create-users::usharik
ChangeSet db/changelog/changes/001-create-users.yaml::001-create-users::usharik ran successfully in 5ms
A changeset is identified by id, author and the file path. Once it has run, do not edit it: Liquibase stores a checksum and refuses to start if an applied changeset changed. Fix mistakes with a new changeset.
Generating a changeset from the entities
Now the entity changes: email becomes mandatory and a display_name appears.
@Column(name = "email", nullable = false, length = 255)
private String email;
@Column(name = "display_name", length = 128)
private String displayName;
You can write the changeset by hand. For bigger changes, let Liquibase compare the database with the entities. That is the job of the Maven plugin with the Hibernate extension. In 2019 it was liquibase-hibernate5; for Hibernate 7 it is liquibase-hibernate7:
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>${liquibase.version}</version>
<configuration>
<propertyFile>liquibase.properties</propertyFile>
</configuration>
<dependencies>
<dependency>
<groupId>org.liquibase.ext</groupId>
<artifactId>liquibase-hibernate7</artifactId>
<version>${liquibase.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
</dependencies>
</plugin>
${liquibase.version} comes from the Spring Boot parent, so the plugin, the extension and the library Boot runs at startup stay on one version. The plugin's settings live in liquibase.properties next to pom.xml:
changeLogFile=db/changelog/db.changelog-master.yaml
searchPath=src/main/resources
url=jdbc:h2:file:./target/demo-db;AUTO_SERVER=TRUE
username=sa
password=
referenceUrl=hibernate:spring:demo.model?dialect=org.hibernate.dialect.H2Dialect
diffChangeLogFile=target/diff-changelog.yaml
changeSetAuthor=usharik
url is the database as it is; referenceUrl is what it should be, here read from the entities in the package demo.model. changeSetAuthor replaces the 2019 trick with the user.name system property; without it the author is your OS user name.
mvn compile liquibase:diff
compile matters: the plugin reads the compiled entity classes. The result:
databaseChangeLog:
- changeSet:
id: 1791372710407-2
author: usharik
changes:
- addColumn:
columns:
- column:
name: display_name
type: VARCHAR(128)
tableName: users
- changeSet:
id: 1791372710407-1
author: usharik
changes:
- addNotNullConstraint:
columnDataType: varchar(255)
columnName: email
tableName: users
validate: true
Treat it as a draft. Read it, give the changesets meaningful ids (the listing above keeps the generated ones), and think about data: addNotNullConstraint on email fails if any row has no email, so in real life a changeset that fills the column goes first. Then save it as changes/002-user-display-name.yaml and add it to the master file. On the next start Liquibase applies both changesets and validate passes.
Rollback, and the trap
Most changes Liquibase can undo by itself: the reverse of addColumn is dropColumn. For changes it cannot invert, such as raw sql, write a rollback block in the changeset. Roll back the last two changesets:
mvn liquibase:rollback -Dliquibase.rollbackCount=2
The first time I ran this, Liquibase answered:
INFO: 0 changesets rolled back.
Rollback command completed successfully.
"Completed successfully", and nothing happened. The cause is the file path. Spring Boot reads the changelog from the classpath, so DATABASECHANGELOG stores paths like db/changelog/changes/002-user-display-name.yaml. My first liquibase.properties pointed the plugin at src/main/resources/db/changelog/..., and for Liquibase that is a different file whose changesets were never applied. Nothing to roll back.
The fix is the pair of settings shown above: changeLogFile relative to the resources folder, and searchPath=src/main/resources. Then the paths match:
Rolling Back Changeset: db/changelog/changes/002-user-display-name.yaml::1791372710407-1::usharik
Rolling Back Changeset: db/changelog/changes/002-user-display-name.yaml::1791372710407-2::usharik
mvn liquibase:update applies them again. Two more commands help before touching a real database: liquibase:updateSQL and liquibase:rollbackSQL write the SQL to target/liquibase/migrate.sql instead of running it, and liquibase:tag marks a point you can roll back to by name with -Dliquibase.rollbackTag=....
Takeaways
ddl-auto=validateplus Liquibase: Liquibase changes the schema, Hibernate checks it.- One change, one file, included from a master changelog. Never edit an applied changeset.
- Diff against the entities to draft a changeset, then edit it like code.
- Keep changelog paths identical between the application and the plugin (
searchPath), or rollbacks will silently find nothing. - Use the versions Spring Boot manages; in 2026 that is Spring Boot 4.1 with Liquibase 5.0 and
liquibase-hibernate7.