You have a working Spring Boot application and a schema that keeps changing. Maybe you added a new column locally, your teammate added a table on a branch, and production is on yet another version. Hibernate ddl-auto used to feel fine in a demo, but it is a liability once real data exists.
Liquibase solves this by turning schema changes into version-controlled, repeatable scripts. In this post, I will walk through a complete Spring Boot 3 + PostgreSQL project using Liquibase YAML changelogs. We’ll cover schema migrations, seed data with loadData, rollback, contexts, and a Testcontainers-based integration test.
If you are still deciding between tools, I also wrote a broader comparison in Flyway vs Liquibase.
What You Need
- Java 17 or later
- Spring Boot 3.3+
- PostgreSQL (local or Docker)
- Maven or Gradle
Project Setup
Create a Spring Boot project with:
- Spring Web
- Spring Data JPA
- PostgreSQL Driver
- Validation
Then add Liquibase:
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>Or with Gradle:
dependencies {
implementation("org.liquibase:liquibase-core")
}No explicit plugin is required for the application to apply migrations on startup, but we will add the Maven/Gradle plugin later for CLI usage.
Configure Spring Boot
Set the changelog path in application.yaml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/taskmanager
username: taskuser
password: taskpass
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
liquibase:
change-log: classpath:/db/changelog/db.changelog-master.yaml
contexts: devThe key line is ddl-auto: validate. Hibernate checks that the database matches the entities but never modifies the schema. Liquibase owns schema evolution.
The Domain
We will build a tiny task manager with projects and tasks.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.JoinColumn;
@Entity
public class Project {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// getters and setters
}import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.JoinColumn;
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "project_id")
private Project project;
// getters and setters
}Changelog Structure
Create src/main/resources/db/changelog/db.changelog-master.yaml:
databaseChangeLog:
- include:
file: classpath:/db/changelog/001-create-projects.yaml
- include:
file: classpath:/db/changelog/002-create-tasks.yaml
- include:
file: classpath:/db/changelog/003-seed-projects.yamlI prefer one file per changeset group. It keeps diffs small and reviews focused.
Changeset 1: Create the Projects Table
# src/main/resources/db/changelog/001-create-projects.yaml
databaseChangeLog:
- changeSet:
id: 001-create-projects
author: fernando.nogueira
changes:
- createTable:
tableName: project
columns:
- column:
name: id
type: BIGSERIAL
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: VARCHAR(255)
constraints:
nullable: false
unique: trueChangeset 2: Create the Tasks Table
# src/main/resources/db/changelog/002-create-tasks.yaml
databaseChangeLog:
- changeSet:
id: 002-create-tasks
author: fernando.nogueira
changes:
- createTable:
tableName: task
columns:
- column:
name: id
type: BIGSERIAL
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: title
type: VARCHAR(500)
constraints:
nullable: false
- column:
name: project_id
type: BIGINT
constraints:
nullable: false
foreignKeyName: fk_task_project
references: project(id)
- createIndex:
indexName: idx_task_project_id
tableName: task
columns:
- column:
name: project_idChangeset 3: Seed Data
Create src/main/resources/db/changelog/seed/projects.csv:
name
Personal Blog
Homelab Dashboard
Invoice ParserThen the changeset:
# src/main/resources/db/changelog/003-seed-projects.yaml
databaseChangeLog:
- changeSet:
id: 003-seed-projects
author: fernando.nogueira
context: dev
changes:
- loadData:
tableName: project
file: classpath:/db/changelog/seed/projects.csv
separator: ","
encoding: UTF-8
columns:
- column:
name: name
type: STRINGThe context: dev ensures this seed data runs only in development. In production, you would omit dev from spring.liquibase.contexts.
Run the Application
Start PostgreSQL. I use Docker Compose:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: taskmanager
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: taskpass
ports:
- "5432:5432"Then:
$ docker compose up -d
$ ./mvnw spring-boot:runOn startup, Liquibase checks the databasechangelog table and runs any missing changesets. The application will fail fast if a migration cannot be applied, which is the behavior you want in production.
Verify the Applied Migrations
Connect to PostgreSQL and check:
SELECT id, author, filename, orderexecuted
FROM databasechangelog
ORDER BY orderexecuted;You should see three rows, one per changeset. Liquibase also created databasechangeloglock to prevent concurrent migration runs.
Rolling Back
Liquibase can reverse changes if you declare a rollback. Add one to the tasks changeset:
# src/main/resources/db/changelog/002-create-tasks.yaml
databaseChangeLog:
- changeSet:
id: 002-create-tasks
author: fernando.nogueira
changes:
- createTable:
tableName: task
# ... columns and index as before
rollback:
- dropTable:
tableName: taskTo roll back the last changeset from the command line, use the Liquibase plugin or CLI:
$ liquibase rollbackCount 1 \
--url=jdbc:postgresql://localhost:5432/taskmanager \
--username=taskuser \
--password=taskpass \
--changeLogFile=src/main/resources/db/changelog/db.changelog-master.yamlSpring Boot does not auto-rollback on startup, which is correct. Rollbacks are a deliberate operation, not a startup routine.
Using the Liquibase Maven Plugin
Add the plugin to pom.xml:
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>4.31.0</version>
<configuration>
<changeLogFile>src/main/resources/db/changelog/db.changelog-master.yaml</changeLogFile>
<url>jdbc:postgresql://localhost:5432/taskmanager</url>
<username>taskuser</username>
<password>taskpass</password>
</configuration>
</plugin>Now you can run:
$ ./mvnw liquibase:status
$ ./mvnw liquibase:update
$ ./mvnw liquibase:rollback -Dliquibase.rollbackCount=1This is especially useful in CI before deploying the application.
Contexts and Environments
Contexts let you run different changesets in different environments. We already used dev for seed data. You can combine them:
spring:
liquibase:
contexts: dev,metricsOr define environment-specific profiles:
---
spring:
config:
activate:
on-profile: prod
liquibase:
contexts: prodThen tag production-only changesets with context: prod. This keeps seed scripts and reporting tables out of production.
Integration Test with Testcontainers
Use Testcontainers to verify migrations against a real PostgreSQL instance during tests.
Add the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>Then a test configuration:
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
@TestConfiguration
public class TestcontainersConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}And the integration test:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Import(TestcontainersConfig.class)
class LiquibaseMigrationIntegrationTest {
@Autowired
private DataSource dataSource;
@Test
void migrationsShouldCreateProjectAndTaskTables() {
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
Integer projectCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM project", Integer.class);
Integer taskCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM task", Integer.class);
assertThat(projectCount).isNotNegative();
assertThat(taskCount).isNotNegative();
}
@Test
void seedDataShouldBeApplied() {
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM project WHERE name IN (?, ?, ?)",
Integer.class,
"Personal Blog", "Homelab Dashboard", "Invoice Parser");
assertThat(count).isEqualTo(3);
}
}This gives you confidence that your changelogs actually produce the schema your JPA entities expect.
Common Pitfalls
1. Editing an Already Applied Changeset
Liquibase checksums every changeset. If you modify 002-create-tasks.yaml after it ran, startup fails with a checksum mismatch. The fix is always to add a new changeset, never to edit the old one.
2. classpath: in Include Paths
The include tag supports both relative paths and classpath:. When you use classpath:, make sure the file exists under src/main/resources. Relative paths are resolved from the changelog file location.
3. Forgetting context Filtering
If seed data runs in production because you forgot to set contexts, you will learn about it the hard way. Always tag non-production data changesets and configure spring.liquibase.contexts per environment.
4. Lock Table Stuck
If a migration is interrupted, the databasechangeloglock row may remain locked. Clear it only after confirming no other process is running migrations:
$ liquibase releaseLocks --url=...5. Hibernate ddl-auto: update
Never combine Liquibase with ddl-auto: update. Either Liquibase owns the schema, or Hibernate does. Running both leads to drift and silent conflicts.
A Minimal Production Checklist
Before deploying Liquibase in production:
- Set
spring.jpa.hibernate.ddl-autotovalidateornone - Pin Liquibase dependency versions in
pom.xmlorbuild.gradle - Run migrations in CI before the application starts, or rely on application startup with a lock timeout
- Tag seed and environment-specific changesets with
context - Add rollback blocks to risky changesets
- Keep a backup before deploying schema migrations
- Add an integration test with Testcontainers
Conclusion
Liquibase and Spring Boot work well together once you let Liquibase own the schema and Hibernate own the object mapping. The combination of YAML changelogs, contexts, and Testcontainers integration tests gives you a migration workflow that is reviewable, repeatable, and safe to run in production.
If you are evaluating Flyway instead, I compared both tools in detail in Flyway vs Liquibase. Pick the tool that fits your team, but pick one. Running database changes manually is the only option that is guaranteed to fail.