Database Migrations with Flyway and Liquibase in Spring Boot
Introduction
This tutorial explains how to manage a database schema with migrations in Spring Boot, using the two common tools, Flyway and Liquibase. We will see whyddl-auto is not enough beyond development, then build the same schema both ways: Flyway with versioned SQL files, and Liquibase with a YAML change log. For each, we will run the application, read the tool's own log, and look at the history table it keeps. We will also see what Hibernate's schema validation reports when the migrations do not run, and list the rules that keep migrations safe in a team.Why migrations
So far in this series, Hibernate created the tables from the entities, withspring.jpa.hibernate.ddl-auto, which Spring Boot sets to create-drop for embedded databases like H2. That is convenient for examples and dangerous anywhere else: create-drop deletes all data on shutdown, and update only adds what is missing, never renames a column, moves data, or removes anything, and does it differently from one version of Hibernate to the next.A migration is a script that changes the schema from one version to the next: create a table, add a column, copy data. Migrations are stored with the code, applied in order, and each one is applied exactly once per database. The tool records which ones have run in a history table in the database itself. Every environment - a developer's laptop, the test server, production - then reaches the same schema by the same steps, and a new version of the application brings its schema changes with it.
In Spring Boot, adding
spring-boot-starter-flyway or spring-boot-starter-liquibase is enough. In Spring Boot 4 the auto-configuration for each tool sits in its own module, which the starter brings in, so add the starter rather than only the tool's core library. Boot then finds the tool, runs pending migrations at startup, before the JPA EntityManagerFactory is created, and switches the ddl-auto default to none. Use one tool per application, not both. The examples here run as two applications, each with its own set of jars.
Flyway: versioned SQL files
Flyway looks for migrations insrc/main/resources/db/migration by default. Each file name says its version and description: V, a version number, two underscores, and a description, as in V1__create_book.sql. Flyway applies them in version order. We set ddl-auto=validate, so that Hibernate creates nothing and only checks that the tables match the entities -
application.properties - Hibernate checks the schema, it does not create it
spring.jpa.hibernate.ddl-auto=validate
V1__create_book.sql - the first migration
create table book (
id bigint generated by default as identity primary key,
title varchar(255) not null
);
V2__add_author.sql - a later change, as a new file
alter table book add column author varchar(255);
insert into book (title, author) values ('Effective Java', 'Joshua Bloch');
insert into book (title, author) values ('Clean Code', 'Robert Martin');
Book.java - the entity matching the migrated schema
//Book.java
package com.javabrahman.data.flyway;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
protected Book() {
}
public String getTitle() { return title; }
public String getAuthor() { return author; }
}
BookRepository.java
//BookRepository.java
package com.javabrahman.data.flyway;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
}
FlywayApp.java - reading the data and Flyway's history table
//FlywayApp.java
package com.javabrahman.data.flyway;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.simple.JdbcClient;
@SpringBootApplication
public class FlywayApp {
@Bean
CommandLineRunner demo(BookRepository books, JdbcClient jdbc) {
return args -> {
books.findAll().forEach(b -> System.out.println("book: " + b.getTitle() + " by " + b.getAuthor()));
System.out.println("flyway_schema_history:");
jdbc.sql("select \"installed_rank\", \"version\", \"description\", \"script\", \"success\" from \"flyway_schema_history\" order by 1")
.query((rs, n) -> " " + rs.getInt(1) + " | " + rs.getString(2) + " | " + rs.getString(3)
+ " | " + rs.getString(4) + " | " + rs.getBoolean(5))
.list()
.forEach(System.out::println);
};
}
public static void main(String[] args) {
SpringApplication.run(FlywayApp.class, args);
}
}
JdbcClient (covered in the next tutorialClick to read tutorial: JdbcClient and Spring Data JDBC: When Not to Use JPA). The program is run with Flyway's log switched on, showing only the log messages -
OUTPUT - FlywayApp with Flyway's own logDatabase: jdbc:h2:mem:657cda25-5aae-43a8-821c-5f13d7ef1655 (H2 2.4) Using H2 2.4.240 which is newer than the version Flyway has been verified with. The latest verified version of H2 is 2.3.232. Schema history table "PUBLIC"."flyway_schema_history" does not exist yet Successfully validated 2 migrations (execution time 00:00.019s) Creating Schema History table "PUBLIC"."flyway_schema_history" ... Current version of schema "PUBLIC": << Empty Schema >> Migrating schema "PUBLIC" to version "1 - create book" Migrating schema "PUBLIC" to version "2 - add author" Successfully applied 2 migrations to schema "PUBLIC", now at version v2 (execution time 00:00.048s) book: Effective Java by Joshua Bloch book: Clean Code by Robert Martin flyway_schema_history: -1 | null | << Flyway Schema History table created >> | | true 1 | 1 | create book | V1__create_book.sql | true 2 | 2 | add author | V2__add_author.sql | true
Explanation of the Flyway output
- Flyway connected to the in-memory H2 database, and warned that H2 2.4.240 is newer than the H2 version this Flyway release was verified with. The migrations still ran; the warning is a reminder to test, which this run did.
- It found no history table, validated the 2 migration files, created the table
flyway_schema_history, and saw an empty schema. - It applied version 1, "create book", and version 2, "add author", in order, ending "now at version v2". The description comes from the file name, with underscores turned into spaces.
- Only then did Hibernate start and validate the schema against the
Bookentity, and the application read the two books inserted byV2. - The history table has one row per migration with its version, description, script and a success flag, plus a first row, rank -1, marking when the table was created. The table also keeps a checksum of each file, and the time it was applied.
V3__...sql, which runs once, on the next start of each environment.What if the schema is not there? The next run is the same application with
spring.flyway.enabled=false, so that no migration runs. The line shown is the error from the failed startup -
OUTPUT - the same app with Flyway switched offFailed to initialize JPA EntityManagerFactory: Unable to build Hibernate SessionFactory [persistence unit: default] ; nested exception is org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing table [book]
ddl-auto=validate, Hibernate refused to start: "Schema validation: missing table [book]". That is the behaviour you want in production. An application whose entities do not match the database fails at startup, with a clear message, instead of failing later on the first query that touches the missing column.
Liquibase: a change log of changesets
Liquibase describes changes as changesets in a change log file, by defaultdb/changelog/db.changelog-master.yaml. A changeset can be written in YAML, XML, JSON or plain SQL. Each one is identified by its id, its author and the file name, and runs once. Changes such as createTable and addColumn are database-independent: Liquibase generates the SQL for the database it runs on. Here is the same schema as before -
db.changelog-master.yaml - the same two changes as Liquibase changesets
databaseChangeLog:
- changeSet:
id: create-book
author: jb
changes:
- createTable:
tableName: book
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
- column:
name: title
type: varchar(255)
constraints:
nullable: false
- changeSet:
id: add-author
author: jb
changes:
- addColumn:
tableName: book
columns:
- column:
name: author
type: varchar(255)
- insert:
tableName: book
columns:
- column: { name: title, value: Effective Java }
- column: { name: author, value: Joshua Bloch }
LiquibaseApp.java - reading Liquibase's change log table
//LiquibaseApp.java
package com.javabrahman.data.liquibase;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.simple.JdbcClient;
@SpringBootApplication
public class LiquibaseApp {
@Bean
CommandLineRunner demo(JdbcClient jdbc) {
return args -> {
jdbc.sql("select title, author from book").query((rs, n) -> "book: " + rs.getString(1) + " by " + rs.getString(2))
.list().forEach(System.out::println);
System.out.println("DATABASECHANGELOG:");
jdbc.sql("select orderexecuted, id, author, filename, exectype from databasechangelog order by orderexecuted")
.query((rs, n) -> " " + rs.getInt(1) + " | " + rs.getString(2) + " | " + rs.getString(3)
+ " | " + rs.getString(4) + " | " + rs.getString(5))
.list()
.forEach(System.out::println);
};
}
public static void main(String[] args) {
SpringApplication.run(LiquibaseApp.class, args);
}
}
book: Effective Java by Joshua Bloch DATABASECHANGELOG: 1 | create-book | jb | db/changelog/db.changelog-master.yaml | EXECUTED 2 | add-author | jb | db/changelog/db.changelog-master.yaml | EXECUTED
Explanation of the Liquibase output
- Liquibase ran both changesets at startup: the table exists, and the row inserted by the second changeset is there.
- Its history table is
DATABASECHANGELOG, with one row per changeset: the order it ran in, theidandauthor, the change log file, andEXECUTED. Like Flyway, it also stores a checksum of each changeset. It keeps a second table,DATABASECHANGELOGLOCK, so that two instances starting at once do not both run the migrations; Flyway uses a database lock for the same purpose. - This application has no entities, so it runs with
ddl-auto=nonepassed on the command line, overriding the shared properties file.
Flyway or Liquibase?
- Flyway: plain SQL files, simple naming, little to learn. You write SQL for your database, and see exactly what runs. It is the common choice when an application uses one database.
- Liquibase: database-independent changes, several file formats, and features such as preconditions (run a changeset only if a condition holds), contexts and labels to select changesets per environment, and rollback definitions. It suits products that must run on several databases.
- Both are well supported by Spring Boot, through
spring.flyway.*andspring.liquibase.*properties for locations, schemas and credentials.
Rules for safe migrations
- Never edit a migration that has been applied. Both tools compare the stored checksum with the file on every start, and refuse to start when an applied migration has changed. Fix mistakes with a new migration.
- Keep
ddl-auto=validate(ornone) outside throwaway development, so that only migrations change the schema. - Make changes backward compatible during rolling deployments: old and new versions of the application run against the same schema for a while. Rename a column in steps - add the new one, copy the data, switch the code, drop the old one in a later release.
- Test migrations against the real database type, for example with Testcontainers, since SQL that works on H2 may not work on PostgreSQL.
- Keep data changes small: a migration that updates millions of rows can hold locks for a long time during startup.
Summary
In this tutorial we saw whyddl-auto is not a way to manage a real schema, and how migration tools apply versioned changes exactly once per database, recording them in a history table. With Flyway, two SQL files V1__create_book.sql and V2__add_author.sql were applied in order at startup, and flyway_schema_history recorded them. With Flyway switched off, Hibernate's validation stopped the application with "missing table [book]". With Liquibase, two YAML changesets built the same schema and were recorded in DATABASECHANGELOG. We compared the two tools and listed rules for safe migrations. The next tutorial looks at data access without JPA: JdbcClient and Spring Data JDBC, compared with JPAClick to read tutorial: JdbcClient and Spring Data JDBC: When Not to Use JPA.Spring Data Tutorials
Optimistic and Pessimistic Locking in Spring Data JPAClick to read tutorial: Optimistic and Pessimistic Locking in Spring Data JPA JdbcClient and Spring Data JDBC: When Not to Use JPAClick to read tutorial: JdbcClient and Spring Data JDBC: When Not to Use JPA Spring Data JPA Introduction: Entities and RepositoriesClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories Spring Data JPA Derived Query Methods ExplainedClick to read tutorial: Spring Data JPA Derived Query Methods Explained Spring Data JPA @Query: JPQL and Native QueriesClick to read tutorial: Spring Data JPA @Query: JPQL and Native Queries JPA Entity Relationships: OneToOne, OneToMany, ManyToManyClick to read tutorial: JPA Entity Relationships: OneToOne, OneToMany, ManyToMany JPA Lazy Loading and the N+1 Problem in Spring BootClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot
Optimistic and Pessimistic Locking in Spring Data JPAClick to read tutorial: Optimistic and Pessimistic Locking in Spring Data JPA JdbcClient and Spring Data JDBC: When Not to Use JPAClick to read tutorial: JdbcClient and Spring Data JDBC: When Not to Use JPA Spring Data JPA Introduction: Entities and RepositoriesClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories Spring Data JPA Derived Query Methods ExplainedClick to read tutorial: Spring Data JPA Derived Query Methods Explained Spring Data JPA @Query: JPQL and Native QueriesClick to read tutorial: Spring Data JPA @Query: JPQL and Native Queries JPA Entity Relationships: OneToOne, OneToMany, ManyToManyClick to read tutorial: JPA Entity Relationships: OneToOne, OneToMany, ManyToMany JPA Lazy Loading and the N+1 Problem in Spring BootClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot