Command Palette

Search for a command to run...

[Spring Boot Basics] Database Migrations in Spring Boot with Flyway: Why ddl-auto Does Not Belong in Production

Article 25 created the products table with schema.sql, which Spring Boot runs only against an embedded database, and articles 26 and 28 let Hibernate generate every table through spring.jpa.hibernate.ddl-auto. Both build a schema from nothing. Neither knows which changes an existing database has already received, and neither leaves a record that a change happened. This article replaces them with Flyway: numbered SQL files in the repository, applied to each database once, in order, and recorded in a table.

The examples use Spring Boot 4.1.1 and Java 21 with PostgreSQL 18 running in Docker. H2 appears where a section says so, MySQL 8.4 in one comparison, and Liquibase at the end. The app runs on port 8131 instead of the default 8080, and long paths in the output are shortened to /…/.

Three versioned migration files, V1, V2 and V3, each arriving in the database as one row of the schema history table

The first section starts from a catalogue whose tables ddl-auto=update created and watches what it does to rows that already exist. The rest builds article 28's schema with Flyway and runs each failure a migration tool is known for.

Why ddl-auto does not belong in production

The database runs in a container, as in article 25:

Bash
docker run -d --name sb-a31-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55431:5432 postgres:18

A postgres profile holds the connection. Its last line belongs to the section on Hibernate and Flyway; the runs in this section override it on the command line:

src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55431/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=validate

The entities are article 28's Category, Tag, Product, Order and OrderLine, without the customer tables and with Order reduced to its id and its lines. Product keeps article 28's relationships and takes its column sizes from article 26:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, length = 120)
    private String name;
 
    @Column(nullable = false, unique = true, length = 40)
    private String sku;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
 
    @Column(nullable = false)
    private int stock;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
 
    @ManyToMany
    @JoinTable(name = "product_tags",
            joinColumns = @JoinColumn(name = "product_id"),
            inverseJoinColumns = @JoinColumn(name = "tag_id"))
    private Set<Tag> tags = new HashSet<>();

For this section a second database in the container, ddlauto, got its tables from update with Flyway switched off:

Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'create database ddlauto;'
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres --spring.datasource.url=jdbc:postgresql://localhost:55431/ddlauto --spring.flyway.enabled=false --spring.jpa.hibernate.ddl-auto=update --logging.level.org.hibernate.SQL=DEBUG --spring.main.web-application-type=none

--spring.main.web-application-type=none makes the process exit once startup is over; every run in this article uses it. update created the six tables, and psql inserted three categories and three products: KB-01 at 89.90, MS-01 at 24.50 and HUB-07 at 39.00. Two copies of that database, made with create database … template ddlauto, serve the second rename below and the section on existing databases.

Renaming a field with ddl-auto=update

The first edit renames name to title, the word storefronts use for a product; the constructor parameter and the getter follow:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, length = 120)
    private String name; 
    private String title; 

Started with update, the application logged the following. The run also carried the SKU change from the next subsection, whose line is shown there:

Text
2026-09-13T17:32:28.040+07:00 DEBUG 42679 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products add column title varchar(120) not null
2026-09-13T17:32:28.041+07:00  WARN 42679 --- [demo] [           main] o.h.t.s.i.ExceptionHandlerLoggedImpl     : GenerationTarget encountered exception accepting command : Error executing DDL "alter table if exists products add column title varchar(120) not null" via JDBC [ERROR: column "title" of relation "products" contains null values]
 
org.hibernate.tool.schema.spi.CommandAcceptanceException: Error executing DDL "alter table if exists products add column title varchar(120) not null" via JDBC [ERROR: column "title" of relation "products" contains null values]
...
Caused by: org.postgresql.util.PSQLException: ERROR: column "title" of relation "products" contains null values
...
2026-09-13T17:32:28.057+07:00  INFO 42679 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-09-13T17:32:28.134+07:00  INFO 42679 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.382 seconds (process running for 1.579)

PostgreSQL refuses to add a NOT NULL column to a table that already has rows. Hibernate logged the refusal as a WARN, and the application started anyway, with a mapping that names a column the table does not have:

Bash
docker exec sb-a31-pg psql -U shop -d ddlauto -c 'select id, name, title, sku from products order by id'
Text
ERROR:  column "title" does not exist
LINE 1: select id, name, title, sku from products order by id
                         ^

With nullable = false removed from the renamed field, the same start against a copy of the database, ddlauto2, got further:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, length = 120) 
    @Column(length = 120) 
    private String title;
Text
2026-09-13T17:34:42.096+07:00 DEBUG 43295 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products add column title varchar(120)
Bash
docker exec sb-a31-pg psql -U shop -d ddlauto2 -c 'select id, name, title, sku, price from products order by id'
Text
 id |        name         | title |  sku   | price
----+---------------------+-------+--------+-------
  1 | Mechanical keyboard |       | KB-01  |    90
  2 | Wireless mouse      |       | MS-01  |    25
  3 | USB-C hub           |       | HUB-07 |    39
(3 rows)

The three names stayed in name, which nothing maps any more, and title exists with nothing in it; the price column belongs to the next subsection. The old column also kept its NOT NULL, so an INSERT with the columns the entity now maps, sent from psql, failed:

Bash
docker exec sb-a31-pg psql -U shop -d ddlauto2 -c "insert into products (title, sku, price, stock, category_id) values ('Webcam', 'CAM-01', 59, 4, 1)"
Text
ERROR:  null value in column "name" of relation "products" violates not-null constraint
DETAIL:  Failing row contains (4, null, 59, CAM-01, 4, 1, Webcam).

update compares the mapping with the table and adds what is missing. Nothing in the entity says that title used to be name, so a rename becomes an added column and the data never moves.

Changing a column's length or scale with update

Each of the two runs carried one more edit. The first widened the SKU:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, unique = true, length = 40) 
    @Column(nullable = false, unique = true, length = 64) 
    private String sku;
Text
2026-09-13T17:32:28.036+07:00 DEBUG 42679 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products alter column sku set data type varchar(64)

The second also removed scale = 2 from price, the kind of edit that slips in while tidying annotations. The default scale of @Column is 0:

src/main/java/com/example/demo/product/Product.java
    @Column(nullable = false, precision = 10, scale = 2) 
    @Column(nullable = false, precision = 10) 
    private BigDecimal price;
Text
2026-09-13T17:34:42.090+07:00 DEBUG 43295 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products alter column price set data type numeric(10,0)
2026-09-13T17:34:42.095+07:00 DEBUG 43295 --- [demo] [           main] org.hibernate.SQL                        : alter table if exists products alter column sku set data type varchar(64)

So Hibernate 7.4's update does alter existing columns. The wider SKU is harmless; the scale is not. PostgreSQL converted the stored values to numeric(10,0), and the psql listing above shows the result: 89.90 became 90 and 24.50 became 25. That run's log contains no WARN or ERROR line at all.

create, create-drop and the missing history

The other two values that change a schema are blunter on a database with data. create drops every mapped table at each start, as article 26's drop table if exists products cascade showed, so every restart begins with empty tables. create-drop does the same and drops them again at shutdown.

And no value leaves a trace. Nothing records which statement ran against which database. The DDL is generated at startup from whatever classes were deployed, so nobody reviews it before it runs. And the result depends on the path a database took: update gave the unique SKU constraint in ddlauto a generated name, ukfhmd06dsmj6k0n90swsh8ie9g, while article 26's create produced products_sku_key for the same mapping.

The same rename as a versioned migration, which a later section runs against the same three products:

Two panels on PostgreSQL 18.6. Left, ddl-auto=update after renaming name to title: Hibernate adds a title column, the three names stay in the unmapped name column, title is NULL in every row and nothing is recorded; with nullable = false the ALTER fails with only a WARN. Right, V4__rename_product_name_to_title.sql renames the column, the rows keep their values and flyway_schema_history records version 4 with checksum 715857858

Adding Flyway to a Spring Boot 4.1.1 project

The Spring Initializr id is flyway. The project for this article was generated with:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,data-jpa,h2,postgresql,flyway" -o demo.zip

Compared with article 26's build, three lines are new:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-flyway'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'org.flywaydb:flyway-database-postgresql'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

The starter brings Boot's Flyway support, which in Boot 4 is a module of its own like the JPA support in article 26, and Flyway itself:

Bash
./gradlew dependencies --configuration runtimeClasspath

Trimmed to the Flyway entries:

Text
+--- org.springframework.boot:spring-boot-starter-flyway -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1 (*)
|    +--- org.springframework.boot:spring-boot-starter-jdbc:4.1.1 (*)
|    +--- org.springframework.boot:spring-boot-flyway:4.1.1
|    |    +--- org.springframework.boot:spring-boot:4.1.1 (*)
|    |    +--- org.springframework.boot:spring-boot-jdbc:4.1.1 (*)
|    |    \--- org.flywaydb:flyway-core:12.4.0
|    |         \--- tools.jackson.core:jackson-databind:3.1.1 -> 3.1.5
|    \--- org.springframework.boot:spring-boot-jdbc:4.1.1 (*)
+--- org.flywaydb:flyway-database-postgresql -> 12.4.0
|    \--- org.flywaydb:flyway-core:12.4.0 (*)
  • flyway-core 12.4.0 is the engine. It carries support for some databases itself: H2's classes are inside it, under org/flywaydb/core/internal/database/h2/, and PostgreSQL's are not.
  • flyway-database-postgresql holds PostgreSQLDatabaseType and, in the same jar, CockroachDB support. Initializr adds it because PostgreSQL was selected; for MySQL it adds org.flywaydb:flyway-mysql.
  • spring-boot-starter-flyway-test is the -test twin every Boot 4 starter has.

Starting without flyway-database-postgresql

With that one dependency removed, the jar still built, and failed at startup against PostgreSQL:

Text
2026-09-13T17:29:10.133+07:00 ERROR 42175 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
 
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]: Failed to initialize dependency 'flywayInitializer' of LoadTimeWeaverAware bean 'entityManagerFactory': Error creating bean with name 'flywayInitializer' defined in class path resource [org/springframework/boot/flyway/autoconfigure/FlywayAutoConfiguration$FlywayConfiguration.class]: Unsupported Database: PostgreSQL 18.6
...
Caused by: org.flywaydb.core.api.FlywayException: Unsupported Database: PostgreSQL 18.6

Nothing complained at build time, because no code refers to the module. The message names the database and its version but not the missing dependency, and PostgreSQL 18.6 is supported: with the module back, the next section's run went through without a single warning about it.

The default migration location and settings

spring.flyway.locations has no default in Boot's property metadata. The value is assigned in the constructor of FlywayProperties, in spring-boot-flyway-4.1.1.jar:

Bash
javap -c -p -classpath spring-boot-flyway-4.1.1.jar org.springframework.boot.flyway.autoconfigure.FlywayProperties
Text
      14: ldc           #15                 // String classpath:db/migration

So Flyway reads src/main/resources/db/migration, the folder Initializr creates, empty. The same constructor sets the naming defaults the following sections rely on: the prefix V, the separator __, the suffix .sql, the repeatable prefix R and the table name flyway_schema_history. The settings this article touches, with their 4.1.1 defaults:

PropertyDefaultWhat the runs showed
spring.flyway.enabledtrueFlyway runs at every start once it is on the classpath
spring.flyway.locationsclasspath:db/migrationset in FlywayProperties, not in the metadata
spring.flyway.validate-on-migratetruean edited migration stops startup
spring.flyway.validate-migration-namingfalsea misnamed file is skipped with an INFO line
spring.flyway.out-of-orderfalsea version below the current one fails validation
spring.flyway.baseline-on-migratefalsea non-empty schema without a history table is refused
spring.flyway.baseline-version1the version the baseline row claims
spring.flyway.clean-disabledtrueclean() throws

Writing versioned migrations for the catalogue schema

The files go into the default location:

Tree
src/main/resources
├── application.properties
├── application-postgres.properties
└── db
    └── migration
        ├── V1__create_catalog.sql
        └── V2__create_orders.sql

V1 creates the catalogue tables:

src/main/resources/db/migration/V1__create_catalog.sql
CREATE TABLE categories (
    id   BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(60) NOT NULL,
    CONSTRAINT uk_categories_name UNIQUE (name)
);
 
CREATE TABLE tags (
    id   BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(40) NOT NULL,
    CONSTRAINT uk_tags_name UNIQUE (name)
);
 
CREATE TABLE products (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name        VARCHAR(120)   NOT NULL,
    sku         VARCHAR(40)    NOT NULL,
    price       NUMERIC(10, 2) NOT NULL,
    stock       INTEGER        NOT NULL,
    category_id BIGINT         NOT NULL,
    CONSTRAINT uk_products_sku UNIQUE (sku),
    CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id)
);
 
CREATE INDEX idx_products_category_id ON products (category_id);
 
CREATE TABLE product_tags (
    product_id BIGINT NOT NULL,
    tag_id     BIGINT NOT NULL,
    CONSTRAINT pk_product_tags PRIMARY KEY (product_id, tag_id),
    CONSTRAINT fk_product_tags_product FOREIGN KEY (product_id) REFERENCES products (id),
    CONSTRAINT fk_product_tags_tag FOREIGN KEY (tag_id) REFERENCES tags (id)
);

V2 creates the orders, whose lines reference products:

src/main/resources/db/migration/V2__create_orders.sql
CREATE TABLE orders (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
);
 
CREATE TABLE order_lines (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    order_id   BIGINT         NOT NULL,
    product_id BIGINT         NOT NULL,
    quantity   INTEGER        NOT NULL,
    unit_price NUMERIC(10, 2) NOT NULL,
    CONSTRAINT fk_order_lines_order FOREIGN KEY (order_id) REFERENCES orders (id),
    CONSTRAINT fk_order_lines_product FOREIGN KEY (product_id) REFERENCES products (id)
);
 
CREATE INDEX idx_order_lines_order_id ON order_lines (order_id);
CREATE INDEX idx_order_lines_product_id ON order_lines (product_id);

The SQL gives Hibernate what the entities expect, plus what Hibernate would not have written:

  • Names match the mapping. products, categories and order_lines come from @Table, category_id from @JoinColumn, product_tags with product_id and tag_id from @JoinTable, and unit_price from the field unitPrice, which Boot's naming strategy turns into snake case, as article 28's DDL showed.
  • BIGINT GENERATED BY DEFAULT AS IDENTITY is the column GenerationType.IDENTITY needs, the definition Hibernate generated in article 26.
  • Constraints have chosen names, uk_products_sku and fk_products_category instead of UKfhmd06dsmj6k0n90swsh8ie9g, so a later migration can refer to them.
  • Foreign key columns get indexes. PostgreSQL does not create them for a foreign key, and the tables update built had none, as the section on existing databases shows.
  • product_tags has a composite primary key, the one Hibernate generated for a Set in article 28.

The first run: what Flyway logs at startup

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres --spring.main.web-application-type=none

Against the empty shop database, from the pool's first connection to the end of startup, with Hibernate's database info block cut:

Text
2026-09-13T17:29:12.738+07:00  INFO 42180 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2026-09-13T17:29:12.747+07:00  INFO 42180 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://localhost:55431/shop (PostgreSQL 18.6)
2026-09-13T17:29:12.774+07:00  INFO 42180 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Schema history table "public"."flyway_schema_history" does not exist yet
2026-09-13T17:29:12.775+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 2 migrations (execution time 00:00.007s)
2026-09-13T17:29:12.785+07:00  INFO 42180 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Creating Schema History table "public"."flyway_schema_history" ...
2026-09-13T17:29:12.821+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": << Empty Schema >>
2026-09-13T17:29:12.825+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - create catalog"
2026-09-13T17:29:12.841+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "2 - create orders"
2026-09-13T17:29:12.850+07:00  INFO 42180 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 2 migrations to schema "public", now at version v2 (execution time 00:00.012s)
2026-09-13T17:29:12.888+07:00  INFO 42180 --- [demo] [           main] org.hibernate.orm.jpa                    : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-09-13T17:29:12.915+07:00  INFO 42180 --- [demo] [           main] org.hibernate.orm.core                   : HHH000001: Hibernate ORM core version 7.4.5.Final
...
2026-09-13T17:29:13.547+07:00  INFO 42180 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-09-13T17:29:13.614+07:00  INFO 42180 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.465 seconds (process running for 1.664)
  • Database: names the URL and the server version. Flyway 12.4.0 said nothing else about PostgreSQL 18.6; on H2 it printed a warning, quoted in the section on Hibernate and Flyway.
  • The history table did not exist, so Flyway created it, then reported << Empty Schema >> and migrated.
  • Each migration gets one line, with its version and a description taken from the file name, underscores turned into spaces.
  • Hibernate started after Flyway had finished: HHH008540 and the EntityManagerFactory come after Successfully applied 2 migrations, and ddl-auto=validate found the tables it expected.
  • Flyway's own version appears nowhere in the log; the dependency tree is where to read it.

flyway_schema_history after the first run

Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'select installed_rank, version, description, type, script, checksum, installed_by, installed_on, execution_time, success from flyway_schema_history order by installed_rank'
Text
 installed_rank | version |  description   | type |         script         | checksum  | installed_by |        installed_on        | execution_time | success
----------------+---------+----------------+------+------------------------+-----------+--------------+----------------------------+----------------+---------
              1 | 1       | create catalog | SQL  | V1__create_catalog.sql |  18327164 | shop         | 2026-09-13 17:29:12.815284 |              8 | t
              2 | 2       | create orders  | SQL  | V2__create_orders.sql  | 678017735 | shop         | 2026-09-13 17:29:12.835719 |              4 | t
(2 rows)

One row per migration. checksum is computed from the file's content and is what the section on editing an applied migration runs into, installed_by is the database user, and execution_time is in milliseconds. After this run psql inserted the same three categories and products into shop as into ddlauto.

Naming rules: prefix, version, double underscore

A versioned migration is named V, a version, two underscores, a description and .sql. Parts of a version are separated by single underscores, so V1_1 is version 1.1. To see how Flyway orders versions and what it does with names that break the pattern, a separate naming database got a folder of one-line migrations, passed as --spring.flyway.locations=filesystem:/…/naming:

Tree
naming
├── V1__create_step.sql
├── V1_1__one_point_one.sql
├── V2__two.sql
├── V9__nine.sql
├── V10__ten.sql
├── V11_single_underscore.sql
├── V13__wrong_suffix.txt
└── v12__lowercase_prefix.sql

The startup log:

Text
2026-09-13T17:29:58.281+07:00  INFO 42246 --- [demo] [           main] o.f.c.i.resource.ResourceNameValidator   : 2 SQL migrations were detected but not run because they did not follow the filename convention.
2026-09-13T17:29:58.281+07:00  INFO 42246 --- [demo] [           main] o.f.c.i.resource.ResourceNameValidator   : Set 'validateMigrationNaming' to true to fail fast and see a list of the invalid file names.
...
2026-09-13T17:29:58.361+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - create step"
2026-09-13T17:29:58.376+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1.1 - one point one"
2026-09-13T17:29:58.384+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "2 - two"
2026-09-13T17:29:58.391+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "9 - nine"
2026-09-13T17:29:58.396+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "10 - ten"
2026-09-13T17:29:58.400+07:00  INFO 42246 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 5 migrations to schema "public", now at version v10 (execution time 00:00.011s)
  • Versions compare as numbers. 10 ran after 9, and 1.1 between 1 and 2, although ls listed V10__ten.sql first.
  • Two files were skipped at INFO level, and the application started without them. The count is 2, not 3: V13__wrong_suffix.txt does not end in .sql, so Flyway does not count it as a SQL migration at all.

With spring.flyway.validate-migration-naming=true the same folder stopped startup and named both files:

Text
Caused by: org.flywaydb.core.api.FlywayException: Invalid SQL filenames found:
Invalid versioned migration name format: V11_single_underscore.sql (could not recognise version number 11_single_underscore)
Unrecognised migration name format: v12__lowercase_prefix.sql

A migration that silently never runs is worse than a start that fails, so the setting is worth turning on:

src/main/resources/application.properties
spring.flyway.validate-migration-naming=true

Editing a migration that has already run

V1 has run on shop. Suppose the SKU needs 64 characters, and the change goes where the column is defined, together with length = 64 on the entity:

src/main/resources/db/migration/V1__create_catalog.sql
CREATE TABLE products (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name        VARCHAR(120)   NOT NULL,
    sku         VARCHAR(40)    NOT NULL, 
    sku         VARCHAR(64)    NOT NULL, 
    price       NUMERIC(10, 2) NOT NULL,

The next start:

Text
2026-09-13T17:31:25.525+07:00 ERROR 42537 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
 
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]: Failed to initialize dependency 'flywayInitializer' of LoadTimeWeaverAware bean 'entityManagerFactory': Error creating bean with name 'flywayInitializer' defined in class path resource [org/springframework/boot/flyway/autoconfigure/FlywayAutoConfiguration$FlywayConfiguration.class]: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : 18327164
-> Resolved locally    : 124720333
Either revert the changes to the migration, or run repair to update the schema history.
Need more flexibility with validation rules? Learn more: https://help.red-gate.com/help/flyway-cli12/help_4.aspx?topic=flyway-blog/older-posts/customize-validation-rules-with-ignoremigrationpatterns
...
Caused by: org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation

validate-on-migrate is true, so Flyway compares every applied migration with its file before it applies anything. 18327164 is the checksum recorded when V1 ran, 124720333 the checksum of the edited file. Without the check, the edit would do nothing to shop, where V1 never runs again, and would reach only databases created from now on, which would then disagree with production about the sku column.

The message offers two ways out. repair would write 124720333 into the history table and silence the difference while shop keeps VARCHAR(40). The other one is right here.

The fix is a new migration

V1 goes back to exactly what ran, and the change becomes the next version:

src/main/resources/db/migration/V3__widen_product_sku.sql
ALTER TABLE products ALTER COLUMN sku TYPE VARCHAR(64);
Text
2026-09-13T17:31:30.632+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 3 migrations (execution time 00:00.011s)
2026-09-13T17:31:30.657+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 2
2026-09-13T17:31:30.662+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "3 - widen product sku"
2026-09-13T17:31:30.675+07:00  INFO 42568 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v3 (execution time 00:00.006s)
2026-09-13T17:31:31.355+07:00  INFO 42568 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d shop -c '\d products' -c 'select installed_rank, version, description, script, checksum, success from flyway_schema_history order by installed_rank'
Text
                                    Table "public.products"
   Column    |          Type          | Collation | Nullable |             Default
-------------+------------------------+-----------+----------+----------------------------------
 id          | bigint                 |           | not null | generated by default as identity
 name        | character varying(120) |           | not null |
 sku         | character varying(64)  |           | not null |
 price       | numeric(10,2)          |           | not null |
 stock       | integer                |           | not null |
 category_id | bigint                 |           | not null |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "idx_products_category_id" btree (category_id)
    "uk_products_sku" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
    "fk_products_category" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
    TABLE "order_lines" CONSTRAINT "fk_order_lines_product" FOREIGN KEY (product_id) REFERENCES products(id)
    TABLE "product_tags" CONSTRAINT "fk_product_tags_product" FOREIGN KEY (product_id) REFERENCES products(id)
 
 installed_rank | version |    description    |          script           |  checksum  | success
----------------+---------+-------------------+---------------------------+------------+---------
              1 | 1       | create catalog    | V1__create_catalog.sql    |   18327164 | t
              2 | 2       | create orders     | V2__create_orders.sql     |  678017735 | t
              3 | 3       | widen product sku | V3__widen_product_sku.sql | 1537025089 | t
(3 rows)

shop has the wider column, V1 still has its original checksum, and a database created tomorrow gets the same column through the same two files. The constraint and index names are the ones V1 chose.

A trace over time with Flyway 12.4.0 on PostgreSQL 18.6: V1 applied and recorded with checksum 18327164; V1 edited in place from VARCHAR(40) to VARCHAR(64); the next startup fails with Migration checksum mismatch for migration version 1, applied 18327164, resolved locally 124720333; the fix restores V1 and adds V3__widen_product_sku.sql, recorded as version 3 with checksum 1537025089

How Flyway and Hibernate share the startup

ddl-auto defaults to none when Flyway manages the database

Article 26 read in HibernateDefaultDdlAutoProvider that Boot gives an embedded database create-drop unless a schema manager such as Flyway manages it. A runner prints what Hibernate received, on in-memory H2 with no datasource settings:

src/main/java/com/example/demo/lab/DdlAutoInspector.java
package com.example.demo.lab;
 
import java.sql.Connection;
 
import javax.sql.DataSource;
 
import jakarta.persistence.EntityManagerFactory;
 
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
 
@Component
@Profile("inspect")
class DdlAutoInspector implements CommandLineRunner {
 
    private final EntityManagerFactory entityManagerFactory;
    private final DataSource dataSource;
    private final ApplicationContext context;
 
    DdlAutoInspector(EntityManagerFactory entityManagerFactory, DataSource dataSource, ApplicationContext context) {
        this.entityManagerFactory = entityManagerFactory;
        this.dataSource = dataSource;
        this.context = context;
    }
 
    @Override
    public void run(String... args) throws Exception {
        System.out.println("hibernate.hbm2ddl.auto = " + entityManagerFactory.getProperties().get("hibernate.hbm2ddl.auto"));
        System.out.println("Flyway beans           = " + java.util.Arrays.toString(context.getBeanNamesForType(org.flywaydb.core.Flyway.class)));
        try (Connection connection = dataSource.getConnection()) {
            var meta = connection.getMetaData();
            System.out.println("database               = " + meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion() + " at " + meta.getURL());
        }
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=inspect --spring.sql.init.mode=never --spring.main.web-application-type=none
Text
hibernate.hbm2ddl.auto = null
Flyway beans           = [flyway]
database               = H2 2.4.240 (2025-09-22) at jdbc:h2:mem:8e4df5a9-a2c3-4186-b038-3d4ccac95aec

The same command with --spring.flyway.enabled=false added:

Text
hibernate.hbm2ddl.auto = create-drop
Flyway beans           = []
database               = H2 2.4.240 (2025-09-22) at jdbc:h2:mem:2601e865-d175-4e70-8110-9f3b73a9097d

null means none: Hibernate left the schema to Flyway, which had built it from V1 and V2 a moment earlier. What decides is whether Flyway is enabled, not whether its jar is present: with Flyway disabled the jar was still there, and create-drop came back. On H2, Flyway 12.4.0 also printed the warning it never printed for PostgreSQL 18.6:

Text
2026-09-13T17:29:53.712+07:00  WARN 42241 --- [demo] [           main] o.f.c.internal.database.base.Database    : 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.

--spring.sql.init.mode=never keeps data.sql, the subject of the last subsection here, out of this run.

Flyway runs before the EntityManagerFactory

The first run's log had every Flyway line before HHH008540, and that order is declared rather than accidental. Every startup failure in this article that came from Flyway began with the same chain: Failed to initialize dependency 'flywayInitializer' of LoadTimeWeaverAware bean 'entityManagerFactory'. Boot creates flywayInitializer, which migrates, before it builds the EntityManagerFactory, so a failed migration means Hibernate never starts. The whole sequence, as logged on shop when V3 was pending:

Six steps in order with log evidence from Spring Boot 4.1.1, Flyway 12.4.0 and PostgreSQL 18.6: scan classpath:db/migration and find V1, V2 and V3; read flyway_schema_history with versions 1 and 2; validate applied checksums, Successfully validated 3 migrations, or Migration checksum mismatch; apply pending versions in order, one transaction each, Migrating schema public to version 3, or failed with changes rolled back; record a row per version, 3 widen product sku 1537025089; start Hibernate with validate, Initialized JPA EntityManagerFactory, or Schema validation: missing column title

ddl-auto=validate against the migrated schema

With spring.jpa.hibernate.ddl-auto=validate in the postgres profile, Hibernate compares the mapping with the tables Flyway built. After V3, the name to title rename from the first section went into the entity without a migration:

Text
2026-09-13T17:32:25.824+07:00  INFO 42677 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.
2026-09-13T17:32:26.513+07:00 ERROR 42677 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Failed to initialize JPA EntityManagerFactory: Unable to build Hibernate SessionFactory  [persistence unit: default] ; nested exception is org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing column [title] in table [products]

Flyway had nothing to do, and Hibernate refused to start. This is the check that catches an entity change shipped without its migration. It is not a full comparison, though. Before V3 existed, the entity already said length = 64 while sku was still VARCHAR(40), and that start passed validation:

Text
2026-09-13T17:31:28.504+07:00  INFO 42552 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.
2026-09-13T17:31:29.195+07:00  INFO 42552 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'

In these runs validate caught a missing column and did not compare a varchar length. A column the entity does not map is not checked either: the description column that V5 adds later has no field, and every start after it passed.

Renaming the column with V4

The fix for the missing column is the migration from the picture in the first section:

src/main/resources/db/migration/V4__rename_product_name_to_title.sql
ALTER TABLE products RENAME COLUMN name TO title;
Text
2026-09-13T17:32:29.682+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 4 migrations (execution time 00:00.012s)
2026-09-13T17:32:29.711+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 3
2026-09-13T17:32:29.716+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "4 - rename product name to title"
2026-09-13T17:32:29.729+07:00  INFO 42696 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v4 (execution time 00:00.004s)
2026-09-13T17:32:30.430+07:00  INFO 42696 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'select id, title, sku from products order by id' -c 'select installed_rank, version, description, script, checksum, success from flyway_schema_history order by installed_rank'
Text
 id |        title        |  sku
----+---------------------+--------
  1 | Mechanical keyboard | KB-01
  2 | Wireless mouse      | MS-01
  3 | USB-C hub           | HUB-07
(3 rows)
 
 installed_rank | version |         description          |                script                |  checksum  | success
----------------+---------+------------------------------+--------------------------------------+------------+---------
              1 | 1       | create catalog               | V1__create_catalog.sql               |   18327164 | t
              2 | 2       | create orders                | V2__create_orders.sql                |  678017735 | t
              3 | 3       | widen product sku            | V3__widen_product_sku.sql            | 1537025089 | t
              4 | 4       | rename product name to title | V4__rename_product_name_to_title.sql |  715857858 | t
(4 rows)

The three products kept their names under the new column name, validate passed, and the change has a row of its own.

data.sql still runs next to Flyway

Article 25 seeded the catalogue with data.sql. The same idea for the new tables:

src/main/resources/data.sql
INSERT INTO categories (name) VALUES ('Keyboards'), ('Mice'), ('Accessories');

On in-memory H2, with DEBUG logging for org.springframework.jdbc.datasource.init:

Text
2026-09-13T17:29:55.309+07:00  INFO 42243 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 2 migrations to schema "PUBLIC", now at version v2 (execution time 00:00.011s)
2026-09-13T17:29:55.317+07:00 DEBUG 42243 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executing SQL script from URL [jar:nested:/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/data.sql]
2026-09-13T17:29:55.318+07:00 DEBUG 42243 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : 3 returned as update count for SQL: INSERT INTO categories (name) VALUES ('Keyboards'), ('Mice'), ('Accessories')
2026-09-13T17:29:55.319+07:00 DEBUG 42243 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executed SQL script from URL [jar:nested:/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/data.sql] in 1 ms.
2026-09-13T17:29:55.350+07:00  INFO 42243 --- [demo] [           main] org.hibernate.orm.jpa                    : HHH008540: Processing PersistenceUnitInfo [name: default]

Flyway does not switch SQL initialization off. Boot ran data.sql after the migrations and before Hibernate, and inserted three rows. On PostgreSQL the file was skipped, because spring.sql.init.mode is embedded, as article 25 measured: the first run on shop had the same file in its jar, and the categories psql inserted afterwards did not collide with the unique name constraint. The same project therefore seeds H2 and not production, and nothing records either.

Delete data.sql and give the data a migration. Data that every environment needs is an ordinary versioned migration in db/migration. Sample data for development goes into a second location:

src/main/resources/db/dev-data/V2_1__sample_products.sql
INSERT INTO categories (name) VALUES ('Keyboards'), ('Mice'), ('Accessories');
 
INSERT INTO products (name, sku, price, stock, category_id) VALUES
    ('Mechanical keyboard', 'KB-01', 89.90, 25, (SELECT id FROM categories WHERE name = 'Keyboards')),
    ('Wireless mouse', 'MS-01', 24.50, 3, (SELECT id FROM categories WHERE name = 'Mice')),
    ('USB-C hub', 'HUB-07', 39.00, 10, (SELECT id FROM categories WHERE name = 'Accessories'));

Only the development profile adds that location:

src/main/resources/application-dev.properties
spring.flyway.locations=classpath:db/migration,classpath:db/dev-data

Started on H2 with that value passed as --spring.flyway.locations, the two folders formed one version sequence:

Text
2026-09-13T17:31:26.330+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 3 migrations (execution time 00:00.004s)
...
2026-09-13T17:31:26.350+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "PUBLIC" to version "1 - create catalog"
2026-09-13T17:31:26.362+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "PUBLIC" to version "2 - create orders"
2026-09-13T17:31:26.366+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "PUBLIC" to version "2.1 - sample products"
2026-09-13T17:31:26.370+07:00  INFO 42539 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 3 migrations to schema "PUBLIC", now at version v2.1 (execution time 00:00.011s)

What happens when a migration fails

PostgreSQL rolls the whole migration back

V5 adds a description column and an index for searching by title, but its second statement was written against the old column name:

src/main/resources/db/migration/V5__add_product_description.sql
ALTER TABLE products ADD COLUMN description VARCHAR(1000);
 
CREATE INDEX idx_products_name ON products (name);
Text
2026-09-13T17:32:32.004+07:00  INFO 42709 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "5 - add product description"
2026-09-13T17:32:32.016+07:00 ERROR 42709 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migration of schema "public" to version "5 - add product description" failed! Changes successfully rolled back.
...
Caused by: org.flywaydb.core.internal.exception.FlywayMigrateException: Failed to execute script V5__add_product_description.sql
--------------------------------------------------------
SQL State  : 42703
Error Code : 0
Message    : ERROR: column "name" does not exist
Location   : db/migration/V5__add_product_description.sql (/…/demo/nested:/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/db/migration/V5__add_product_description.sql)
Line       : 3
Statement  : Run Flyway with -X option to see the actual statement causing the problem

The first statement had succeeded before the second failed. psql afterwards:

Bash
docker exec sb-a31-pg psql -U shop -d shop -c '\d products'
Text
                                    Table "public.products"
   Column    |          Type          | Collation | Nullable |             Default
-------------+------------------------+-----------+----------+----------------------------------
 id          | bigint                 |           | not null | generated by default as identity
 title       | character varying(120) |           | not null |
 sku         | character varying(64)  |           | not null |
 price       | numeric(10,2)          |           | not null |
 stock       | integer                |           | not null |
 category_id | bigint                 |           | not null |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "idx_products_category_id" btree (category_id)
    "uk_products_sku" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
    "fk_products_category" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
    TABLE "order_lines" CONSTRAINT "fk_order_lines_product" FOREIGN KEY (product_id) REFERENCES products(id)
    TABLE "product_tags" CONSTRAINT "fk_product_tags_product" FOREIGN KEY (product_id) REFERENCES products(id)

There is no description column, and the history query returned the same four rows as after V4, with nothing for version 5. PostgreSQL executes DDL inside a transaction, Flyway ran the whole file in one, and the rollback took the ALTER TABLE with it; transactions themselves are the subject of article 30. Because version 5 never ran anywhere, its file can still be corrected:

src/main/resources/db/migration/V5__add_product_description.sql
ALTER TABLE products ADD COLUMN description VARCHAR(1000);
 
CREATE INDEX idx_products_name ON products (name); 
CREATE INDEX idx_products_title ON products (title); 

The corrected V5 applied on the next start, whose log is in the section on repeatable migrations.

MySQL 8.4 keeps half of it

MySQL commits every DDL statement on its own. The same two statements ran as version 2 on mysql:8.4, after a MySQL V1 that created products with id, title and sku, with org.flywaydb:flyway-mysql and com.mysql:mysql-connector-j added to the build:

Text
2026-09-13T17:37:59.639+07:00  INFO 44431 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema `shop` to version "2 - add product description"
2026-09-13T17:37:59.657+07:00 ERROR 44431 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migration of schema `shop` to version "2 - add product description" failed! Please restore backups and roll back database and code!
...
SQL State  : 42000
Error Code : 1072
Message    : Key column 'name' doesn't exist in table
Bash
docker exec sb-a31-mysql mysql -t -ushop -psecret shop -e "describe products; select installed_rank, version, description, type, script, checksum, success from flyway_schema_history order by installed_rank;" 2>/dev/null
Text
+-------------+---------------+------+-----+---------+----------------+
| Field       | Type          | Null | Key | Default | Extra          |
+-------------+---------------+------+-----+---------+----------------+
| id          | bigint        | NO   | PRI | NULL    | auto_increment |
| title       | varchar(120)  | NO   |     | NULL    |                |
| sku         | varchar(64)   | NO   |     | NULL    |                |
| description | varchar(1000) | YES  |     | NULL    |                |
+-------------+---------------+------+-----+---------+----------------+
+----------------+---------+-------------------------+------+---------------------------------+-------------+---------+
| installed_rank | version | description             | type | script                          | checksum    | success |
+----------------+---------+-------------------------+------+---------------------------------+-------------+---------+
|              1 | 1       | create products         | SQL  | V1__create_products.sql         |  1839980680 |       1 |
|              2 | 2       | add product description | SQL  | V2__add_product_description.sql | -1426013997 |       0 |
+----------------+---------+-------------------------+------+---------------------------------+-------------+---------+

The column from the first statement stayed, and version 2 has a row with success 0. The next start refused to go on:

Text
Caused by: org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation
Detected failed migration to version 2 (add product description).
Please remove any half-completed changes then run repair to fix the schema history.

On MySQL, one DDL statement per migration keeps a failure from leaving half a migration behind.

When flyway repair is legitimate

repair does two jobs, and its log names both: it removes the rows of failed migrations and it realigns the recorded checksums with the files. On MySQL above, the message asks for the first after the half-added column has been dropped by hand. The second is legitimate when a file changed without changing what it does. A comment added to V1 during a review is such a change:

src/main/resources/db/migration/V1__create_catalog.sql
-- Catalogue tables: categories, tags, products and the product_tags join table
CREATE TABLE categories (

Boot calls a FlywayMigrationStrategy bean, when there is one, instead of running migrate() itself. Two such beans, each behind its own profile, did the repair here and the clean attempt later in the article:

src/main/java/com/example/demo/lab/FlywayLab.java
package com.example.demo.lab;
 
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
 
@Configuration(proxyBeanMethods = false)
class FlywayLab {
 
    @Bean
    @Profile("repair")
    FlywayMigrationStrategy repairThenMigrate() {
        return flyway -> {
            flyway.repair();
            flyway.migrate();
        };
    }
 
    @Bean
    @Profile("clean")
    FlywayMigrationStrategy cleanThenMigrate() {
        return flyway -> {
            flyway.clean();
            flyway.migrate();
        };
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres,repair --spring.main.web-application-type=none
Text
2026-09-13T17:34:44.115+07:00  INFO 43317 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Repair of failed migration in Schema History table "public"."flyway_schema_history" not necessary. No failed migration detected.
2026-09-13T17:34:44.127+07:00  INFO 43317 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Repairing Schema History table for version 1 (Description: create catalog, Type: SQL, Checksum: 115815086)  ...
2026-09-13T17:34:44.130+07:00  INFO 43317 --- [demo] [           main] o.f.core.internal.command.DbRepair       : Successfully repaired schema history table "public"."flyway_schema_history" (execution time 00:00.024s).
...
2026-09-13T17:34:44.181+07:00  INFO 43317 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 5
2026-09-13T17:34:44.182+07:00  INFO 43317 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.
Bash
docker exec sb-a31-pg psql -U shop -d shop -c "select version, description, checksum from flyway_schema_history where version = '1'"
Text
 version |  description   | checksum
---------+----------------+-----------
 1       | create catalog | 115815086
(1 row)

The comment alone moved V1's checksum from 18327164 to 115815086, so without repair it would have stopped startup like the SKU edit did. Start once with the profile, then without it: left on, the strategy would repair every edit silently at every start, the SKU edit included. Outside the application, Flyway's command-line tool has the same repair command; this article did not use it.

Repeatable migrations with the R__ prefix

A file whose name starts with R__ has no version. Flyway runs it after all pending versioned migrations and runs it again whenever its checksum changes, which suits objects that are replaced as a whole, such as a view:

src/main/resources/db/migration/R__product_catalog_view.sql
CREATE OR REPLACE VIEW product_catalog AS
SELECT p.id, p.sku, p.title, c.name AS category, p.price, p.stock
FROM products p
JOIN categories c ON c.id = p.category_id;

It went into the same build as the corrected V5 and ran right after it:

Text
2026-09-13T17:32:33.614+07:00  INFO 42742 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "5 - add product description"
2026-09-13T17:32:33.629+07:00  INFO 42742 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" with repeatable migration "product catalog view"
2026-09-13T17:32:33.637+07:00  INFO 42742 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 2 migrations to schema "public", now at version v5 (execution time 00:00.009s)

Then the view gained an in-stock flag:

src/main/resources/db/migration/R__product_catalog_view.sql
CREATE OR REPLACE VIEW product_catalog AS
SELECT p.id, p.sku, p.title, c.name AS category, p.price, p.stock 
SELECT p.id, p.sku, p.title, c.name AS category, p.price, p.stock, p.stock > 0 AS in_stock 
FROM products p
JOIN categories c ON c.id = p.category_id;
Text
2026-09-13T17:32:35.992+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 7 migrations (execution time 00:00.014s)
2026-09-13T17:32:36.020+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 5
2026-09-13T17:32:36.025+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" with repeatable migration "product catalog view"
2026-09-13T17:32:36.038+07:00  INFO 42755 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public" (execution time 00:00.004s)
Bash
docker exec sb-a31-pg psql -U shop -d shop -c 'select installed_rank, version, description, type, script, checksum, success from flyway_schema_history order by installed_rank' -c 'select * from product_catalog order by id'
Text
 installed_rank | version |         description          | type |                script                |  checksum  | success
----------------+---------+------------------------------+------+--------------------------------------+------------+---------
              1 | 1       | create catalog               | SQL  | V1__create_catalog.sql               |   18327164 | t
              2 | 2       | create orders                | SQL  | V2__create_orders.sql                |  678017735 | t
              3 | 3       | widen product sku            | SQL  | V3__widen_product_sku.sql            | 1537025089 | t
              4 | 4       | rename product name to title | SQL  | V4__rename_product_name_to_title.sql |  715857858 | t
              5 | 5       | add product description      | SQL  | V5__add_product_description.sql      |  809655632 | t
              6 |         | product catalog view         | SQL  | R__product_catalog_view.sql          | -439945004 | t
              7 |         | product catalog view         | SQL  | R__product_catalog_view.sql          | 1231863337 | t
(7 rows)
 
 id |  sku   |        title        |  category   | price | stock | in_stock
----+--------+---------------------+-------------+-------+-------+----------
  1 | KB-01  | Mechanical keyboard | Keyboards   | 89.90 |    25 | t
  2 | MS-01  | Wireless mouse      | Mice        | 24.50 |     3 | t
  3 | HUB-07 | USB-C hub           | Accessories | 39.00 |    10 | t
(3 rows)
  • A repeatable migration has no version, and every run adds a row: 6 and 7 are the two states of the view, with different checksums.
  • An unchanged file does not run again. The later start with the repair profile, with the view file untouched, ended in Schema "public" is up to date. No migration necessary.
  • The file must be safe to run repeatedly, which is why it uses CREATE OR REPLACE VIEW.

The folder at this point:

Tree
src/main/resources/db
├── dev-data
│   └── V2_1__sample_products.sql
└── migration
    ├── R__product_catalog_view.sql
    ├── V1__create_catalog.sql
    ├── V2__create_orders.sql
    ├── V3__widen_product_sku.sql
    ├── V4__rename_product_name_to_title.sql
    └── V5__add_product_description.sql

clean-disabled and out-of-order

clean drops every object in the schemas Flyway manages, tables, views and the history table included, as the Flyway documentation describes the command. Boot 4.1.1 sets spring.flyway.clean-disabled=true. The clean profile of FlywayLab called flyway.clean() against shop:

Text
Caused by: org.flywaydb.core.api.FlywayException: Unable to execute clean as it has been disabled with the 'flyway.cleanDisabled' property.

Afterwards \dt listed the same seven tables and \dv the view: the call threw before it touched anything. Leave the default in place for every database whose data matters.

spring.flyway.out-of-order concerns a version lower than one already applied. In the naming database, at version 10, a new V3__late.sql stopped startup:

Text
Caused by: org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation
Detected resolved migration not applied to database: 3.
To ignore this migration, set -ignoreMigrationPatterns='*:ignored'. To allow executing this migration, set -outOfOrder=true.

With --spring.flyway.out-of-order=true it ran, under a warning: outOfOrder mode is active. Migration of schema "public" may not be reproducible. The situation arises when two branches each add a migration; giving the later one the next free number is usually cleaner than turning the setting on.

Bringing an existing database under Flyway with baseline

The legacy database is the copy of ddlauto made before the renames: six tables from update, three products, no history table. The Flyway build started against it:

Text
Caused by: org.flywaydb.core.api.FlywayException: Found non-empty schema(s) "public" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.

Flyway will not run V1 on top of tables that already exist. A baseline tells it which version the database already corresponds to. legacy has what V1 and V2 create, so the baseline is version 2:

src/main/resources/application-postgres.properties
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=2

Passed on the command line for the run against legacy:

Text
2026-09-13T17:34:46.935+07:00  INFO 43331 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Schema history table "public"."flyway_schema_history" does not exist yet
2026-09-13T17:34:46.936+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 6 migrations (execution time 00:00.009s)
2026-09-13T17:34:46.945+07:00  INFO 43331 --- [demo] [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Creating Schema History table "public"."flyway_schema_history" with baseline ...
2026-09-13T17:34:46.959+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbBaseline     : Successfully baselined schema with version: 2
2026-09-13T17:34:46.983+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 2
2026-09-13T17:34:46.986+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "3 - widen product sku"
2026-09-13T17:34:46.995+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "4 - rename product name to title"
2026-09-13T17:34:47.001+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "5 - add product description"
2026-09-13T17:34:47.008+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" with repeatable migration "product catalog view"
2026-09-13T17:34:47.014+07:00  INFO 43331 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 4 migrations to schema "public", now at version v5 (execution time 00:00.007s)
2026-09-13T17:34:47.683+07:00  INFO 43331 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d legacy -c 'select installed_rank, version, description, type, script, checksum, success from flyway_schema_history order by installed_rank' -c '\d products'
Text
 installed_rank | version |         description          |   type   |                script                |  checksum  | success
----------------+---------+------------------------------+----------+--------------------------------------+------------+---------
              1 | 2       | << Flyway Baseline >>        | BASELINE | << Flyway Baseline >>                |            | t
              2 | 3       | widen product sku            | SQL      | V3__widen_product_sku.sql            | 1537025089 | t
              3 | 4       | rename product name to title | SQL      | V4__rename_product_name_to_title.sql |  715857858 | t
              4 | 5       | add product description      | SQL      | V5__add_product_description.sql      |  809655632 | t
              5 |         | product catalog view         | SQL      | R__product_catalog_view.sql          | 1231863337 | t
(5 rows)
 
                                     Table "public.products"
   Column    |          Type           | Collation | Nullable |             Default
-------------+-------------------------+-----------+----------+----------------------------------
 id          | bigint                  |           | not null | generated by default as identity
 title       | character varying(120)  |           | not null |
 price       | numeric(10,2)           |           | not null |
 sku         | character varying(64)   |           | not null |
 stock       | integer                 |           | not null |
 category_id | bigint                  |           | not null |
 description | character varying(1000) |           |          |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "idx_products_title" btree (title)
    "ukfhmd06dsmj6k0n90swsh8ie9g" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
    "fkog2rp4qthbtt2lfyhfo32lsw9" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
    TABLE "product_tags" CONSTRAINT "fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
    TABLE "order_lines" CONSTRAINT "fk5v1oeejtgtf2n3toppm3tkuhh" FOREIGN KEY (product_id) REFERENCES products(id)
  • The first row is the baseline, type BASELINE, version 2, with no checksum. V1 and V2 never ran on legacy; V3, V4, V5 and the view did.
  • The rest of the schema is still what update built. The unique constraint is ukfhmd06dsmj6k0n90swsh8ie9g, the foreign key fkog2rp4qthbtt2lfyhfo32lsw9, and category_id has no index. A database built from V1 has uk_products_sku and idx_products_category_id instead, so a later migration that names those finds nothing on legacy. Align the two with a migration of its own before any migration depends on a constraint name.
  • Remove baseline-on-migrate after the first run. Left on, Flyway baselines any non-empty schema without a history table instead of refusing, and that refusal was the useful part of the first attempt. Set baseline-version to the highest version whose changes the database already has; every version above it runs.

Generating a first migration from the entities

Writing V1 by hand for a model that already exists is slow, and Hibernate can write a draft. Jakarta Persistence's schema-generation properties produce a script instead of changing the database; with Flyway and ddl-auto both off, the application wrote one for the current entities:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8131 --spring.profiles.active=postgres --spring.flyway.enabled=false --spring.jpa.hibernate.ddl-auto=none --spring.jpa.properties.jakarta.persistence.schema-generation.scripts.action=create --spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-target=build/generated-schema/create.sql --spring.main.web-application-type=none
build/generated-schema/create.sql
create table categories (id bigint generated by default as identity, name varchar(60) not null unique, primary key (id));
create table order_lines (quantity integer not null, unit_price numeric(10,2) not null, id bigint generated by default as identity, order_id bigint not null, product_id bigint not null, primary key (id));
create table orders (id bigint generated by default as identity, primary key (id));
create table product_tags (product_id bigint not null, tag_id bigint not null, primary key (product_id, tag_id));
create table products (price numeric(10,2) not null, stock integer not null, category_id bigint not null, id bigint generated by default as identity, sku varchar(64) not null unique, title varchar(120) not null, primary key (id));
create table tags (id bigint generated by default as identity, name varchar(40) not null unique, primary key (id));
alter table if exists order_lines add constraint FK1smc0s578t2oih21yn9hw6usr foreign key (order_id) references orders;
alter table if exists order_lines add constraint FK5v1oeejtgtf2n3toppm3tkuhh foreign key (product_id) references products;
alter table if exists product_tags add constraint FKpur2885qb9ae6fiquu77tcv1o foreign key (tag_id) references tags;
alter table if exists product_tags add constraint FK5rk6s19k3risy7q7wqdr41uss foreign key (product_id) references products;
alter table if exists products add constraint FKog2rp4qthbtt2lfyhfo32lsw9 foreign key (category_id) references categories;
  • Hibernate created build/generated-schema itself, put each statement on one line and ended it with ;. shop was not touched: \dt still listed seven tables and the history table still had seven rows.
  • The script is a draft, not V1. The foreign keys have generated names, the unique constraints have no name at all, no foreign key column has an index, and the column order is Hibernate's. Review it, name the constraints, add the indexes, and only then save it as the first versioned migration.
  • A second run appends. With --spring.jpa.properties.hibernate.format_sql=true added, the file was not replaced: a formatted copy of the same statements followed the first one, and create.sql grew to 78 lines. Delete the file before generating again.

Liquibase in brief

Liquibase is the other migration tool Spring Boot configures on its own. A copy of the project swapped the Flyway lines for these:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-liquibase'
	testImplementation 'org.springframework.boot:spring-boot-starter-liquibase-test'
}

The jar contained liquibase-core-5.0.3.jar, snakeyaml-2.6.jar and opencsv-5.12.0.jar, and no PostgreSQL module was needed. Boot's metadata gives spring.liquibase.change-log the default classpath:/db/changelog/db.changelog-master.yaml, and Initializr creates src/main/resources/db/changelog, empty. One changeset:

src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - changeSet:
      id: create-categories
      author: catalog-team
      changes:
        - createTable:
            tableName: categories
            columns:
              - column:
                  name: id
                  type: BIGINT
                  autoIncrement: true
                  constraints:
                    primaryKey: true
              - column:
                  name: name
                  type: VARCHAR(60)
                  constraints:
                    nullable: false
                    unique: true
                    uniqueConstraintName: uk_categories_name

Against a new database, liqui, trimmed:

Text
2026-09-13T17:38:04.011+07:00  INFO 44447 --- [demo] [           main] liquibase.changelog                      : Creating database changelog table with name: public.databasechangelog
2026-09-13T17:38:04.072+07:00  INFO 44447 --- [demo] [           main] liquibase.lockservice                    : Successfully acquired change log lock
2026-09-13T17:38:04.080+07:00  INFO 44447 --- [demo] [           main] liquibase.ui                             : Running Changeset: db/changelog/db.changelog-master.yaml::create-categories::catalog-team
2026-09-13T17:38:04.086+07:00  INFO 44447 --- [demo] [           main] liquibase.changelog                      : Table categories created
2026-09-13T17:38:04.087+07:00  INFO 44447 --- [demo] [           main] liquibase.changelog                      : ChangeSet db/changelog/db.changelog-master.yaml::create-categories::catalog-team ran successfully in 7ms
2026-09-13T17:38:04.092+07:00  INFO 44447 --- [demo] [           main] liquibase.util                           : UPDATE SUMMARY
2026-09-13T17:38:04.092+07:00  INFO 44447 --- [demo] [           main] liquibase.util                           : Run:                          1
2026-09-13T17:38:04.092+07:00  INFO 44447 --- [demo] [           main] liquibase.util                           : Previously run:               0
...
2026-09-13T17:38:04.093+07:00  INFO 44447 --- [demo] [           main] liquibase.ui                             : Liquibase: Update has been successful. Rows affected: 0
2026-09-13T17:38:04.096+07:00  INFO 44447 --- [demo] [           main] liquibase.lockservice                    : Successfully released change log lock
2026-09-13T17:38:04.790+07:00  INFO 44447 --- [demo] [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Bash
docker exec sb-a31-pg psql -U shop -d liqui -c '\dt' -c '\d categories' -c 'select id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, liquibase from databasechangelog'
Text
                 List of tables
 Schema |         Name          | Type  | Owner
--------+-----------------------+-------+-------
 public | categories            | table | shop
 public | databasechangelog     | table | shop
 public | databasechangeloglock | table | shop
(3 rows)
 
                                Table "public.categories"
 Column |         Type          | Collation | Nullable |             Default
--------+-----------------------+-----------+----------+----------------------------------
 id     | bigint                |           | not null | generated by default as identity
 name   | character varying(60) |           | not null |
Indexes:
    "categories_pkey" PRIMARY KEY, btree (id)
    "uk_categories_name" UNIQUE CONSTRAINT, btree (name)
 
        id         |    author    |               filename                |        dateexecuted        | orderexecuted | exectype |               md5sum               |           description            | liquibase
-------------------+--------------+---------------------------------------+----------------------------+---------------+----------+------------------------------------+----------------------------------+-----------
 create-categories | catalog-team | db/changelog/db.changelog-master.yaml | 2026-09-13 17:38:04.087142 |             1 | EXECUTED | 9:46b21e15b2b647823a77b4c3c475729f | createTable tableName=categories | 5.0.3
(1 row)

A changeset is identified by its id, its author and its changelog file, all three stored in databasechangelog, with md5sum as its checksum. databasechangeloglock holds the lock the log acquired and released around the update. The BIGINT column with autoIncrement became generated by default as identity: Liquibase wrote the DDL for the database it found.

Flyway vs Liquibase

Flyway 12.4.0Liquibase 5.0.3
Boot 4.1.1 starterspring-boot-starter-flywayspring-boot-starter-liquibase
PostgreSQL supporta separate module, flyway-database-postgresqlinside liquibase-core
Default locationclasspath:db/migrationclasspath:/db/changelog/db.changelog-master.yaml
Unit of changea file, V3__widen_product_sku.sqla changeset in a changelog, create-categories by catalog-team
Orderthe version in the file namethe position in the changelog
What you writeSQL for one databasechange types such as createTable, turned into DDL for the database it finds
Historyflyway_schema_historydatabasechangelog and databasechangeloglock
Recorded checksum183271649:46b21e15b2b647823a77b4c3c475729f

Pick one per project. This series continues with Flyway: its migrations are plain PostgreSQL SQL, and the next article adds one.

ddl-auto values vs versioned migrations on a production database

ApproachAt startup, on a database that already has dataSeen in this article
nonenothing; the schema has to come from somewhere elseBoot's default on PostgreSQL, and on H2 when Flyway is enabled
validatecompares the mapping with the tables and changes nothingstopped on missing column [title]; accepted length = 64 against VARCHAR(40)
updateadds tables and columns and alters column types; a rename becomes a new columnan empty title column, or a failed ALTER logged as a WARN; numeric(10,0) rounded 89.90 to 90
createdrops the mapped tables and creates them againevery restart starts with empty tables (article 26)
create-droplike create, and drops them again at shutdownBoot's default on H2 with Flyway disabled
Flyway versioned migrationsapplies each pending file once, in version order, in its own transaction on PostgreSQL, and records itV4 renamed the column with its data; an edited V1 stopped startup; a broken V5 rolled back with no history row

FAQ

Why should ddl-auto=update not be used in production?

Because it changes a live schema from whatever entity classes were deployed, with no review and no record. On PostgreSQL 18.6 with Hibernate 7.4.5, renaming a field added an empty column and left the data in the old one, whose NOT NULL then rejected inserts; with nullable = false the ALTER failed and only a WARN was logged; and removing scale = 2 rounded every price. Nothing in the database recorded any of it.

What does "Unsupported Database: PostgreSQL 18.6" mean in Flyway?

That flyway-database-postgresql is missing. In Flyway 12.4.0, flyway-core contains no PostgreSQL support; the module does. Add org.flywaydb:flyway-database-postgresql next to spring-boot-starter-flyway, as Spring Initializr does when PostgreSQL is selected.

Where does Spring Boot look for Flyway migrations?

In classpath:db/migration, which is src/main/resources/db/migration in a Gradle or Maven project. In Spring Boot 4.1.1 the default is set in the constructor of FlywayProperties, not in the property metadata. spring.flyway.locations changes it and accepts several locations, which Flyway merges into one version sequence.

How do I fix "Migration checksum mismatch" in Flyway?

Put the migration back exactly as it ran and move the change into a new versioned migration, as V3 did for the SKU here. Use repair only when the edit does not change what the migration does, such as an added comment, or to clear a failed migration's row on a database without transactional DDL, and run it once, for example through a FlywayMigrationStrategy bean behind a profile.

Does data.sql still run when Flyway is enabled?

Yes, on an embedded database. With Spring Boot 4.1.1 and H2, data.sql ran after Flyway's migrations and before Hibernate started. On PostgreSQL the default spring.sql.init.mode=embedded skips it. Put data every environment needs into a versioned migration, and development data into a second location that only the development profile adds.

How do I add Flyway to an existing database?

Set spring.flyway.baseline-on-migrate=true and spring.flyway.baseline-version to the last version the database already matches, start once, then remove the first setting. Without it, Flyway 12.4.0 stops with Found non-empty schema(s) "public" but no schema history table. Expect constraint names and indexes that differ from a database built from the migrations.

Conclusion

ddl-auto generates DDL from the classes that happen to be deployed. On a PostgreSQL database with data, update turned a renamed field into an empty column or a WARN, left the data behind a NOT NULL that blocked inserts, and rounded prices when a scale disappeared, and nothing recorded any of it. Flyway turns each change into a numbered SQL file that runs once per database, in version order, before Hibernate starts. flyway_schema_history keeps every file's checksum, so an edited V1 stopped startup until the change moved into V3; a broken V5 rolled back completely on PostgreSQL while MySQL kept half of it; and validate refused an entity whose column had no migration.

In Spring Boot 4.1.1 that means spring-boot-starter-flyway plus flyway-database-postgresql, whose absence reads Unsupported Database: PostgreSQL 18.6, migrations in classpath:db/migration, ddl-auto at none or validate, and no data.sql, which still runs next to Flyway on H2. An existing database joins with a one-time baseline, views belong in repeatable migrations, clean stays disabled, and Hibernate's generated script is a draft to review before it becomes a migration.

The next article builds on these tables with auditing: @CreatedDate, @LastModifiedDate and @CreatedBy record who created and changed a row and when, and a new migration adds their columns.

Related Posts

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi on Spring Boot 4.1.1: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] Authorization in Spring Security: Roles, @PreAuthorize, CORS and CSRF

Authorization in Spring Security on Spring Boot 4.1.1, for a JWT API: authorities and the ROLE_ prefix, hasRole vs hasAuthority, what hasRole("ROLE_ADMIN") does in a URL rule versus in SpEL, authorizeHttpRequests rules for the catalogue with 401, 403 and 200 exchanges, @EnableMethodSecurity, @PreAuthorize and @PostAuthorize with an ownership rule, the self-invocation trap and the catch-all advice that turns a method-security 403 into a 500, a RoleHierarchy bean, CORS with a preflight blocked in headless Chrome and answered by http.cors and a CorsConfigurationSource, allowedOrigins("*") with allowCredentials, and CSRF on a session chain with csrf.spa, the XSRF-TOKEN cookie and X-XSRF-TOKEN header, and why the bearer API can disable it.

[Spring Boot Basics] JPA Auditing in Spring Boot: @CreatedDate, @LastModifiedDate and @CreatedBy

Spring Data JPA auditing on Spring Boot 4.1.1 with PostgreSQL: @EnableJpaAuditing, AuditingEntityListener and a @MappedSuperclass base class, a Flyway migration adding NOT NULL audit columns to a table with rows, the silent nulls without the annotation or the listener, Instant vs LocalDateTime vs OffsetDateTime and what timestamptz stores, when @LastModifiedDate moves and what modifyOnCreate changes, the detached save() that writes null into created_at and @Column(updatable = false), @CreatedBy from an X-User header through AuditorAware, a Clock-backed DateTimeProvider, the bulk and native updates that bypass auditing, and Hibernate @CreationTimestamp and @UpdateTimestamp compared.

[Spring Boot Basics] Java Prerequisites for Spring Boot: OOP, Generics, Streams, Records and Annotations

The Java you need before Spring Boot 4.1.1: interfaces and polymorphism, List/Set/Map, generics and type erasure, lambdas and Stream, Optional, records as DTOs, and the one that matters most — custom annotations read back with reflection, exactly how @Component and @GetMapping work.