Article 31 put the catalogue schema under Flyway migrations with spring.jpa.hibernate.ddl-auto=validate. A catalogue table also has to answer the two questions every support ticket starts with: when was this row created or last changed, and by whom. Four columns hold the answers, created_at, updated_at, created_by and updated_by, and Spring Data JPA can fill them on every INSERT and UPDATE through an entity listener, with no code in the service.
This article adds them to article 26's Product and then runs each claim that usually comes with the feature: which timestamp types work, when each field moves, what modifyOnCreate changes, how a detached entity wipes created_at, where the user name comes from before Spring Security exists, and which statements never reach the listener. The examples use Spring Boot 4.1.1 and Java 21 with PostgreSQL 18 running in Docker, on an Initializr project with the web, validation, Spring Data JPA, H2, PostgreSQL and Flyway dependencies. The app runs on port 8132 instead of the default 8080. The JVM time zone is Asia/Ho_Chi_Minh (UTC+7) and the psql sessions use PostgreSQL's default Etc/UTC, which matters in the section on types.
![]()
The first half sets auditing up and watches each field change; the second half covers the traps, the alternatives and the API. Lab runs print only the logger's short name and the message (logging.pattern.console=%logger{0}: %msg%n); HTTP runs use Boot's default log pattern.
Why audit columns, and what goes wrong when the service sets them
Audit columns answer operational questions the business data cannot: when a price last changed, which rows an import touched, who created a product that should not exist. They hold only the latest change, not a history; a full change log is a different tool, Hibernate Envers, covered in the Advanced course.
The direct way is to set the fields in the service. A trimmed Product with two Instant fields and setters, and a service that remembers them in two of its three methods:
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Transactional
public Product create(Product product) {
Instant now = Instant.now();
product.setCreatedAt(now);
product.setUpdatedAt(now);
return repository.save(product);
}
@Transactional
public Product changePrice(Long id, BigDecimal price) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(price);
product.setUpdatedAt(Instant.now());
return product;
}
@Transactional
public Product restock(Long id, int quantity) {
Product product = repository.findById(id).orElseThrow();
product.setStock(product.getStock() + quantity);
return product;
}
}A runner called the three methods with a short pause between them. On PostgreSQL, with the SELECT and bind lines removed:
ProbeRunner: -- create
SQL: insert into products (created_at,name,price,sku,stock,updated_at) values (?,?,?,?,?,?)
ProbeRunner: Product[id=1, sku=KB-01, price=89.90, stock=25, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:47.101087Z]
ProbeRunner: -- changePrice
SQL: update products set created_at=?,name=?,price=?,sku=?,stock=?,updated_at=? where id=?
ProbeRunner: Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:48.663469Z]
ProbeRunner: -- restock
SQL: update products set created_at=?,name=?,price=?,sku=?,stock=?,updated_at=? where id=?
ProbeRunner: Product[id=1, sku=KB-01, price=84.90, stock=35, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:48.663469Z] id | sku | price | stock | created_at | updated_at
----+-------+-------+-------+-------------------------------+-------------------------------
1 | KB-01 | 84.90 | 35 | 2026-09-13 10:41:47.101087+00 | 2026-09-13 10:41:48.663469+00
(1 row)The stock went from 25 to 35 and updated_at still claims nothing has changed since 10:41:48. There was no error and no warning; the third method was simply written without the line. Every new write path, an import, a scheduled job, a second service, has to remember both fields, and nothing checks that it did.
Setting up JPA auditing in Spring Boot
No dependency is added. @CreatedDate, @LastModifiedDate, @CreatedBy and @LastModifiedBy live in org.springframework.data.annotation in spring-data-commons 4.1.1, and @EnableJpaAuditing and AuditingEntityListener in spring-data-jpa 4.1.1, both already on the classpath through spring-boot-starter-data-jpa. Three pieces are needed: auditing switched on, a listener on the entity, and annotated fields.
@EnableJpaAuditing in a configuration class
package com.example.demo.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
class AuditingConfig {
}@EnableJpaAuditing registers an AuditingHandler bean named jpaAuditingHandler and the bean definition of AuditingEntityListener that receives it. Its four attributes, read with javap from the 4.1.1 jar, are auditorAwareRef, setDates, modifyOnCreate and dateTimeProviderRef; later sections use the last three. The class sits in common, where the beans it needs are added later, rather than on DemoApplication.
AuditableEntity: a @MappedSuperclass with @EntityListeners
package com.example.demo.common;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate
@Column(nullable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@CreatedBy
@Column(length = 64)
private String createdBy;
@LastModifiedBy
@Column(length = 64)
private String updatedBy;
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public String getCreatedBy() { return createdBy; }
public String getUpdatedBy() { return updatedBy; }
}@MappedSuperclassmaps these fields into the table of every entity that extends the class. It has no table of its own.@EntityListeners(AuditingEntityListener.class)makes JPA call the listener around each write. In the 4.1.1 jar itstouchForCreatemethod carries@PrePersistandtouchForUpdatecarries@PreUpdate.- The four annotations mark what to fill: two timestamps and two user names. The fields have getters and no setters, because no application code should write them.
@Columnnames nothing:createdAtbecomescreated_atthrough Boot's naming strategy, as the SQL below shows. Withddl-auto=validatethe attributes generate no DDL; they record what the migration creates.
Product extends the class and prints the new fields:
package com.example.demo.product;
import java.math.BigDecimal;
import com.example.demo.common.AuditableEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
public class Product extends AuditableEntity {
// fields, constructors, getters, setters, equals and hashCode as in article 26
@Override
public String toString() {
return "Product[id=" + id + ", sku=" + sku + ", price=" + price + ", stock=" + stock + ", status=" + status + "]";
return "Product[id=" + id + ", sku=" + sku + ", price=" + price + ", stock=" + stock
+ ", createdAt=" + getCreatedAt() + ", updatedAt=" + getUpdatedAt()
+ ", createdBy=" + getCreatedBy() + ", updatedBy=" + getUpdatedBy() + "]";
}
}The project also has an AuditorAware bean, the source of created_by, shown in its own section below. Outside a web request it returns nothing, which is why the lab runs leave both user columns null.
A Flyway migration for a table that already has rows
The entity now maps four columns the table does not have, and validate refuses to start until they exist. The postgres profile:
spring.datasource.url=jdbc:postgresql://localhost:55432/catalog
spring.datasource.username=catalog
spring.datasource.password=catalog
spring.jpa.hibernate.ddl-auto=validateIn this article's project products came from V1__create_products_table.sql and already held three rows:
id | sku | price | stock
----+-------+-------+-------
1 | KB-01 | 89.90 | 25
2 | KB-02 | 59.00 | 12
3 | MS-01 | 24.50 | 3
(3 rows)The migration takes the next free version: V2 in this article's project, and V6__add_audit_columns_to_products.sql in the series project, after article 31's V5__add_product_description.sql; that project's products table has article 31's title column in place of name, which the audit columns do not touch. The obvious first attempt:
alter table products
add column created_at timestamp with time zone not null,
add column updated_at timestamp with time zone not null,
add column created_by varchar(64),
add column updated_by varchar(64);./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --spring.main.web-application-type=none2026-09-13T17:33:59.179+07:00 INFO 43166 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Current version of schema "public": 1
2026-09-13T17:33:59.184+07:00 INFO 43166 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Migrating schema "public" to version "2 - add audit columns to products"
2026-09-13T17:33:59.193+07:00 ERROR 43166 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Migration of schema "public" to version "2 - add audit columns to products" failed! Changes successfully rolled back.
...
Caused by: org.flywaydb.core.internal.exception.FlywayMigrateException: Failed to execute script V2__add_audit_columns_to_products.sql
SQL State : 23502
Error Code : 0
Message : ERROR: column "created_at" of relation "products" contains null values
Location : db/migration/V2__add_audit_columns_to_products.sql (...)
Line : 1
Statement : Run Flyway with -X option to see the actual statement causing the problemPostgreSQL has to give the three existing rows a value in the new column, and a NOT NULL column with no default has none. SQL State 23502 is a not-null violation. PostgreSQL runs DDL inside a transaction, so "Changes successfully rolled back" is literal: the table is unchanged, and the history table still lists only V1.
docker exec sb-a32-pg psql -U catalog -d catalog -c "select installed_rank, version, description, success from flyway_schema_history" installed_rank | version | description | success
----------------+---------+-----------------------+---------
1 | 1 | create products table | t
(1 row)A default fills the existing rows while the column is added:
alter table products
add column created_at timestamp with time zone not null,
add column updated_at timestamp with time zone not null,
add column created_at timestamp with time zone not null default now(),
add column updated_at timestamp with time zone not null default now(),
add column created_by varchar(64),
add column updated_by varchar(64);The same command, rebuilt:
2026-09-13T17:35:23.158+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbValidate : Successfully validated 2 migrations (execution time 00:00.011s)
2026-09-13T17:35:23.185+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Current version of schema "public": 1
2026-09-13T17:35:23.191+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Migrating schema "public" to version "2 - add audit columns to products"
2026-09-13T17:35:23.208+07:00 INFO 43428 --- [demo] [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "public", now at version v2 (execution time 00:00.006s)
2026-09-13T17:35:24.240+07:00 INFO 43428 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 1.827 seconds (process running for 2.035)Started DemoApplication means Hibernate's schema validation passed: timestamp with time zone is accepted for an Instant field and varchar(64) for a String. The table and its rows:
docker exec sb-a32-pg psql -U catalog -d catalog -c "\d products" -c "select id, sku, created_at, updated_at, created_by, updated_by from products order by id" 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(40) | | not null |
price | numeric(10,2) | | not null |
stock | integer | | not null |
category | character varying(60) | | not null |
status | character varying(20) | | not null |
created_at | timestamp with time zone | | not null | now()
updated_at | timestamp with time zone | | not null | now()
created_by | character varying(64) | | |
updated_by | character varying(64) | | |
Indexes:
"products_pkey" PRIMARY KEY, btree (id)
"products_sku_key" UNIQUE CONSTRAINT, btree (sku)
id | sku | created_at | updated_at | created_by | updated_by
----+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
2 | KB-02 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
3 | MS-01 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
(3 rows)All three rows got the same value in both columns: now() returns the start time of the current transaction, and the whole migration ran in one. For old rows that value is the migration time, not the real creation time, which nobody recorded. The user columns stay nullable, because the existing rows have no author and not every write comes from a request. The default stays on the timestamp columns; it only applies when an INSERT leaves the column out, and Hibernate never does, as the next section shows.
The first INSERT and where the listener runs
The lab runs use one ApplicationRunner behind a lab profile, started without a web server, running the steps named in --lab.steps, and a bean that holds the transactional steps:
package com.example.demo.lab;
import java.math.BigDecimal;
import java.time.ZoneId;
import java.util.List;
import com.example.demo.product.CreateProductRequest;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import com.example.demo.product.ProductService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("lab")
class AuditLab implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(AuditLab.class);
private final ProductRepository repository;
private final ProductService service;
private final LabTransactions tx;
private final List<String> steps;
AuditLab(ProductRepository repository, ProductService service, LabTransactions tx,
@Value("${lab.steps}") List<String> steps) {
this.repository = repository;
this.service = service;
this.tx = tx;
this.steps = steps;
}
@Override
public void run(ApplicationArguments args) {
log.info("JVM time zone: {}", ZoneId.systemDefault());
for (String step : steps) {
log.info("-- {}", step);
try {
runStep(step);
} catch (RuntimeException e) {
for (Throwable t = e; t != null; t = t.getCause()) {
log.info("!!! {}: {}", t.getClass().getName(), t.getMessage());
}
}
}
}
private void runStep(String step) {
switch (step) {
case "create" -> {
Product saved = repository.save(new Product("USB-C hub", "HUB-01", new BigDecimal("39.00"), 10, "Accessories"));
log.info("returned {}", saved);
}
case "create2" -> {
Product saved = repository.save(new Product("Laptop stand", "AC-02", new BigDecimal("34.90"), 15, "Accessories"));
log.info("returned {}", saved);
}
case "update" -> log.info("returned {}", tx.changePrice("KB-01", "84.90"));
case "noop" -> log.info("returned {}", tx.readOnly("KB-01"));
case "saveall" -> {
List<Product> keyboards = repository.findAll().stream()
.filter(p -> p.getCategory().equals("Keyboards")).toList();
keyboards.forEach(p -> p.setStock(p.getStock() + 1));
repository.saveAll(keyboards).forEach(p -> log.info("returned {}", p));
}
case "bulk" -> log.info("changePrices -> {} rows", service.changePrices("Keyboards", new BigDecimal("1.10")));
case "native" -> log.info("restock -> {} rows", service.restock("MS-01", 5));
case "replace" -> {
Long id = repository.findBySku("HUB-01").orElseThrow().getId();
log.info("returned {}", service.replace(id, new CreateProductRequest("USB-C hub, 7 ports", "HUB-01",
new BigDecimal("42.00"), 8, "Accessories")));
}
default -> throw new IllegalArgumentException("unknown step " + step);
}
}
}package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile("lab")
public class LabTransactions {
private static final Logger log = LoggerFactory.getLogger(LabTransactions.class);
private final ProductRepository repository;
LabTransactions(ProductRepository repository) {
this.repository = repository;
}
@Transactional
public Product changePrice(String sku, String price) {
Product product = repository.findBySku(sku).orElseThrow();
product.setPrice(new BigDecimal(price));
log.info("price set to {}, save() not called, leaving the method", price);
return product;
}
@Transactional
public Product readOnly(String sku) {
Product product = repository.findBySku(sku).orElseThrow();
log.info("loaded {}, nothing changed, leaving the method", product.getSku());
return product;
}
}To see who calls the listener, getCurrentAuditor got a temporary first line, new Exception("getCurrentAuditor called from").printStackTrace(System.out), switched on with a system property:
java -Dlab.trace=true -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab --spring.main.web-application-type=none --lab.steps=createOn PostgreSQL, with the stack trace cut to the frames that matter:
AuditLab: JVM time zone: Asia/Ho_Chi_Minh
AuditLab: -- create
java.lang.Exception: getCurrentAuditor called from
at com.example.demo.common.RequestHeaderAuditorAware.getCurrentAuditor(RequestHeaderAuditorAware.java:17)
at org.springframework.data.auditing.AuditingHandler.getAuditor(AuditingHandler.java:103)
at org.springframework.data.auditing.AuditingHandler.markCreated(AuditingHandler.java:86)
at org.springframework.data.jpa.domain.support.AuditingEntityListener.touchForCreate(AuditingEntityListener.java:93)
at org.hibernate.jpa.event.internal.ListenerCallback.performCallback(ListenerCallback.java:56)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.callback(CallbackRegistryImpl.java:117)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.preCreate(CallbackRegistryImpl.java:66)
at org.hibernate.event.internal.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:206)
...
at org.hibernate.internal.SessionImpl.persist(SessionImpl.java:692)
...
at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:664)
...
at com.example.demo.lab.AuditLab.runStep(AuditLab.java:57)
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:36:02.071310Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [USB-C hub]
bind: binding parameter (5:NUMERIC) <- [39.00]
bind: binding parameter (6:VARCHAR) <- [HUB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [10]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:02.071310Z]
bind: binding parameter (10:VARCHAR) <- [null]
AuditLab: returned Product[id=4, sku=HUB-01, price=39.00, stock=10, createdAt=2026-09-13T10:36:02.071310Z, updatedAt=2026-09-13T10:36:02.071310Z, createdBy=null, updatedBy=null]Read from the bottom up: save on a new entity called SessionImpl.persist, Hibernate ran the entity's @PrePersist callbacks through CallbackRegistryImpl.preCreate, which called AuditingEntityListener.touchForCreate, which called AuditingHandler.markCreated. The handler asks AuditorAware for the user first and then asks its DateTimeProvider for the time: in the bytecode of AuditingHandlerSupport, touchAuditor runs before touchDate, and touchDate calls getNow() once and sets both timestamps from that value. Only after the fields were set did Hibernate build the INSERT, so the statement already carries them: the same 2026-09-13T10:36:02.071310Z in created_at and updated_at, and null in both user columns. Every audit column is in the column list, which is why the default now() from the migration never applies to rows the application writes.

What happens without @EnableJpaAuditing or the entity listener?
Two more runs of the create2 step, a laptop stand with SKU AC-02: one with the @EnableJpaAuditing line deleted from AuditingConfig, one with the @EntityListeners line deleted from AuditableEntity. Both applications started with no warning in the log, and the first run failed on the INSERT:
AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [null]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [null]
bind: binding parameter (10:VARCHAR) <- [null]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).] [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]; SQL [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]; constraint [created_at]
AuditLab: !!! org.hibernate.exception.ConstraintViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).] [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]
AuditLab: !!! org.postgresql.util.PSQLException: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).The run without @EntityListeners printed the same bind values and the same three exceptions, with id 7 in the failing row. Neither mistake produces an error of its own. Without @EnableJpaAuditing there is no handler; without the listener Hibernate never calls one; either way the four fields stay null and Hibernate binds four nulls. The NOT NULL constraint on created_at is what stopped the statement, and the column's default now() did not help, because the INSERT sent an explicit null. Against columns without the constraint, those four nulls are what would be stored. Keep NOT NULL on the timestamps: it turns a missing setup into a failure on the first write instead of rows with no dates.
Instant, LocalDateTime or OffsetDateTime for @CreatedDate?
The type question needs three entities that differ only in the type of their two fields, so it ran in a separate probe project against a database named probes, with ddl-auto=create. The probe configuration chooses the DateTimeProvider from a property; default returns CurrentDateTimeProvider.INSTANCE, the provider AuditingHandler uses when none is configured:
package com.example.demo.probe;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.CurrentDateTimeProvider;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "labDateTimeProvider")
class ProbeConfig {
@Bean
DateTimeProvider labDateTimeProvider(@Value("${lab.provider:default}") String mode) {
return switch (mode) {
case "default" -> CurrentDateTimeProvider.INSTANCE;
case "instant" -> () -> Optional.of(Instant.now());
case "offset" -> () -> Optional.of(OffsetDateTime.now());
default -> throw new IllegalArgumentException(mode);
};
}
} @Entity
@Table(name = "instant_probe")
@EntityListeners(AuditingEntityListener.class)
public static class InstantProbe implements Stamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@CreatedDate private Instant createdAt;
@LastModifiedDate private Instant updatedAt;
// getId, setId, setName and toString
}LocalDateTimeProbe and OffsetDateTimeProbe are identical except for the type. The DDL Hibernate generated for them on PostgreSQL:
SQL: create table instant_probe (created_at timestamp(6) with time zone, id bigint generated by default as identity, updated_at timestamp(6) with time zone, name varchar(255), primary key (id))
SQL: create table local_date_time_probe (created_at timestamp(6), id bigint generated by default as identity, updated_at timestamp(6), name varchar(255), primary key (id))
SQL: create table offset_date_time_probe (created_at timestamp(6) with time zone, id bigint generated by default as identity, updated_at timestamp(6) with time zone, name varchar(255), primary key (id))Each probe was persisted, then loaded and renamed in a second transaction a second later. With the default provider:
ProbeRunner: -- persist InstantProbe
ProbeRunner: InstantProbe[id=1, name=null, createdAt=2026-09-13T10:41:50.204205Z, updatedAt=2026-09-13T10:41:50.204205Z]
ProbeRunner: -- rename InstantProbe
ProbeRunner: InstantProbe[id=1, name=renamed, createdAt=2026-09-13T10:41:50.204205Z, updatedAt=2026-09-13T10:41:51.423762Z]
ProbeRunner: -- persist LocalDateTimeProbe
ProbeRunner: LocalDateTimeProbe[id=1, name=null, createdAt=2026-09-13T17:41:51.431023, updatedAt=2026-09-13T17:41:51.431023]
ProbeRunner: -- rename LocalDateTimeProbe
ProbeRunner: LocalDateTimeProbe[id=1, name=renamed, createdAt=2026-09-13T17:41:51.431023, updatedAt=2026-09-13T17:41:52.647257]
ProbeRunner: -- persist OffsetDateTimeProbe
ProbeRunner: !!! java.lang.IllegalArgumentException: Cannot convert unsupported date type java.time.LocalDateTime to java.time.OffsetDateTime; Supported types are [java.time.LocalDateTime, java.time.LocalDate, java.time.LocalTime, java.time.Instant, java.util.Date, java.lang.Long, long]CurrentDateTimeProvider.getNow() returns LocalDateTime.now(), as its bytecode shows. The handler converts that value to the field's type with a ConversionService built from Spring's DefaultFormattingConversionService plus Spring Data's Jsr310Converters. Instant and LocalDateTime are on the supported list; OffsetDateTime is not, and the save failed before any SQL was sent.
What PostgreSQL stores in timestamp with time zone and timestamp
docker exec sb-a32-pg psql -U catalog -d probes -c "show timezone" -c "select * from instant_probe" -c "select * from local_date_time_probe" TimeZone
----------
Etc/UTC
(1 row)
created_at | id | updated_at | name
-------------------------------+----+-------------------------------+---------
2026-09-13 10:41:50.204205+00 | 1 | 2026-09-13 10:41:51.423762+00 | renamed
(1 row)
created_at | id | updated_at | name
----------------------------+----+----------------------------+---------
2026-09-13 17:41:51.431023 | 1 | 2026-09-13 17:41:52.647257 | renamed
(1 row)Instantintimestamp with time zoneis an absolute point in time. The JVM ran at UTC+7 andpsqlin UTC, and the value reads10:41:50+00, the moment the row was written, whoever reads it.LocalDateTimeintimestampholds the JVM's wall clock,17:41:51, with no zone. The conversion fromLocalDateTime.now()used the JVM's zone; nothing in the row records it, so every reader has to know which zone the writer ran in.- The JDBC session is not the
psqlsession. A PostgreSQL error printed later in this article showed atimestamp with time zonevalue as2026-09-13 17:37:28.930979+07, the JVM's zone, whilepsqlshowed the same kind of value in+00. The stored instant is the same; only the display differs.
OffsetDateTime and the DateTimeProvider
With a provider returning Instant.now(), Instant and LocalDateTime worked again, and the third probe failed with a different source type:
ProbeRunner: !!! java.lang.IllegalArgumentException: Cannot convert unsupported date type java.time.Instant to java.time.OffsetDateTime; Supported types are [java.time.LocalDateTime, java.time.LocalDate, java.time.LocalTime, java.time.Instant, java.util.Date, java.lang.Long, long]Only a provider returning OffsetDateTime.now() made all three work:
ProbeRunner: -- persist OffsetDateTimeProbe
ProbeRunner: OffsetDateTimeProbe[id=1, name=null, createdAt=2026-09-13T17:42:01.218922+07:00, updatedAt=2026-09-13T17:42:01.218922+07:00]
ProbeRunner: -- rename OffsetDateTimeProbe
ProbeRunner: OffsetDateTimeProbe[id=1, name=renamed, createdAt=2026-09-13T10:42:01.218922Z, updatedAt=2026-09-13T17:42:02.438637+07:00] created_at | id | updated_at | name
-------------------------------+----+-------------------------------+---------
2026-09-13 10:42:01.218922+00 | 1 | 2026-09-13 10:42:02.438637+00 | renamed
(1 row)The offset is not stored: the +07:00 set by the provider came back from the database as Z in createdAt, the value Hibernate loaded for the rename.
| Field type | Column on PostgreSQL | Default provider | Provider returning Instant | Provider returning OffsetDateTime | Stored value |
|---|---|---|---|---|---|
Instant | timestamp(6) with time zone | works | works | works | 2026-09-13 10:41:50.204205+00 |
LocalDateTime | timestamp(6) | works | works | works | 2026-09-13 17:41:51.431023, JVM wall clock |
OffsetDateTime | timestamp(6) with time zone | IllegalArgumentException | IllegalArgumentException | works | 2026-09-13 10:42:01.218922+00, offset dropped |
The series uses Instant: it works with the default provider and with the Clock-based one later, and its column stores an unambiguous moment.
When do @CreatedDate and @LastModifiedDate change?
Back in the main project on catalog. The update, noop, saveall, bulk and native runs followed each other, so each psql output below is the state the next of them started from.
On insert, and modifyOnCreate = false
The first INSERT above bound 2026-09-13T10:36:02.071310Z to both created_at and updated_at. With the default modifyOnCreate = true, the handler sets the last-modified fields on create as well, from the same value. Setting it to false:
@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(modifyOnCreate = false)
class AuditingConfig {
}The create2 step, on PostgreSQL:
AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:40:52.354547Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [null]
bind: binding parameter (10:VARCHAR) <- [null]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "updated_at" of relation "products" violates not-null constraint
Detail: Failing row contains (8, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, 2026-09-13 17:40:52.354547+07, null, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "updated_at" of relation "products" violates not-null constraintcreated_at was set and updated_at was bound as null, which the NOT NULL column rejected. modifyOnCreate = false means "never modified" is stored as null, so it needs a nullable updated_at, and every query that sorts by it has to handle null. The rest of the article keeps the default.
On an update through dirty checking
The update step calls LabTransactions.changePrice, which loads KB-01 and changes its price without calling save. With the stack trace switched on again:
AuditLab: -- update
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [KB-01]
LabTransactions: price set to 84.90, save() not called, leaving the method
java.lang.Exception: getCurrentAuditor called from
at com.example.demo.common.RequestHeaderAuditorAware.getCurrentAuditor(RequestHeaderAuditorAware.java:17)
at org.springframework.data.auditing.AuditingHandler.getAuditor(AuditingHandler.java:103)
at org.springframework.data.auditing.AuditingHandler.markModified(AuditingHandler.java:98)
at org.springframework.data.jpa.domain.support.AuditingEntityListener.touchForUpdate(AuditingEntityListener.java:113)
at org.hibernate.jpa.event.internal.ListenerCallback.performCallback(ListenerCallback.java:56)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.callback(CallbackRegistryImpl.java:117)
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.preUpdate(CallbackRegistryImpl.java:80)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.invokeInterceptor(DefaultFlushEntityEventListener.java:341)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.handleInterception(DefaultFlushEntityEventListener.java:325)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.scheduleUpdate(DefaultFlushEntityEventListener.java:247)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.onFlushEntity(DefaultFlushEntityEventListener.java:146)
...
at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:498)
at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2100)
...
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:552)
...
at com.example.demo.lab.LabTransactions$$SpringCGLIB$$0.changePrice(<generated>)
at com.example.demo.lab.AuditLab.runStep(AuditLab.java:64)
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Mechanical keyboard]
bind: binding parameter (5:NUMERIC) <- [84.90]
bind: binding parameter (6:VARCHAR) <- [KB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [25]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:50.394160Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [1]
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:50.394160Z, createdBy=null, updatedBy=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 84.90 | 25 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:50.39416+00 | |
2 | KB-02 | 59.00 | 12 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
3 | MS-01 | 24.50 | 3 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
4 | HUB-01 | 39.00 | 10 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:36:02.07131+00 | |
(4 rows)The callback did not run when the price was set. It ran after the method had returned, while Spring committed the transaction: JpaTransactionManager.doCommit flushed the session, DefaultFlushEntityEventListener found KB-01 dirty and scheduled an update, and only then was @PreUpdate called and touchForUpdate set the new updatedAt. The UPDATE lists every column, as article 26 showed for dirty checking; created_at is sent again with the value that was loaded. In the table only row 1 changed, and only its price and updated_at.
A transaction that changes nothing
The noop step loads KB-01 in a transaction and returns it untouched:
AuditLab: -- noop
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [KB-01]
LabTransactions: loaded KB-01, nothing changed, leaving the method
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:50.394160Z, createdBy=null, updatedBy=null]No UPDATE, and psql printed exactly the four rows above. The flush found nothing dirty, so there was no update to schedule and no @PreUpdate to call. Reading an entity inside a read-write transaction does not touch updated_at.
saveAll on detached entities
The saveall step loads all products without a transaction, adds one to the stock of each keyboard and passes the detached list to saveAll:
AuditLab: -- saveall
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [2]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [1]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Compact keyboard]
bind: binding parameter (5:NUMERIC) <- [59.00]
bind: binding parameter (6:VARCHAR) <- [KB-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [13]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:55.037375Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [2]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Mechanical keyboard]
bind: binding parameter (5:NUMERIC) <- [84.90]
bind: binding parameter (6:VARCHAR) <- [KB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [26]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:55.040412Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [1]
AuditLab: returned Product[id=2, sku=KB-02, price=59.00, stock=13, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:55.037375Z, createdBy=null, updatedBy=null]
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=26, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:55.040412Z, createdBy=null, updatedBy=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 84.90 | 26 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.040412+00 | |
2 | KB-02 | 59.00 | 13 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.037375+00 | |
3 | MS-01 | 24.50 | 3 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
4 | HUB-01 | 39.00 | 10 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:36:02.07131+00 | |
(4 rows)saveAll is save in a loop inside one transaction. Each keyboard was merged, a SELECT copying it into the persistence context, and the flush ran the listener once per entity: two UPDATEs, two different updated_at values three milliseconds apart. saveAll is audited like save. The merge that made this work is also where the next trap lives.
The detached entity trap: save() writes null into created_at
A PUT that builds a new Product from the request
A common shortcut for PUT reuses the create DTO, builds a new Product from it and sets the id from the path:
@Transactional
public ProductResponse replace(Long id, CreateProductRequest request) {
Product product = mapper.toProduct(request);
product.setId(id);
return mapper.toResponse(repository.save(product));
} public Long getId() { return id; }
void setId(Long id) { this.id = id; } The object has an id, so save merges it, article 26's SELECT then UPDATE. It also has no createdAt and no createdBy, because nothing in a request sets them. The replace step on HUB-01, on PostgreSQL:
AuditLab: -- replace
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [HUB-01]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [4]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [null]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [USB-C hub, 7 ports]
bind: binding parameter (5:NUMERIC) <- [42.00]
bind: binding parameter (6:VARCHAR) <- [HUB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [8]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:37:28.930979Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [4]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
Detail: Failing row contains (4, USB-C hub, 7 ports, HUB-01, 42.00, 8, Accessories, ACTIVE, null, 2026-09-13 17:37:28.930979+07, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraintThe first SELECT is the runner looking up the id; the second is the merge. Merge copies the whole state of the new object onto the managed instance, nulls included, so the managed Product lost its createdAt and createdBy. At the flush the listener set updatedAt, as it should, and never looked at the created fields: touchForUpdate only writes the last-modified pair. The UPDATE bound null to created_at and to created_by. created_by is nullable, so its null would have gone through; only the constraint on created_at stopped the statement.
@Column(updatable = false)
The created fields should never be part of an UPDATE, and JPA can say so:
@CreatedDate
@Column(nullable = false)
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@CreatedBy
@Column(length = 64)
@Column(length = 64, updatable = false)
private String createdBy;The same replace step:
AuditLab: -- replace
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [HUB-01]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [4]
SQL: update products set category=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:VARCHAR) <- [USB-C hub, 7 ports]
bind: binding parameter (3:NUMERIC) <- [42.00]
bind: binding parameter (4:VARCHAR) <- [HUB-01]
bind: binding parameter (5:VARCHAR) <- [ACTIVE]
bind: binding parameter (6:INTEGER) <- [8]
bind: binding parameter (7:TIMESTAMP_UTC) <- [2026-09-13T10:43:41.465017Z]
bind: binding parameter (8:VARCHAR) <- [null]
bind: binding parameter (9:BIGINT) <- [4]
AuditLab: returned ProductResponse[id=4, name=USB-C hub, 7 ports, sku=HUB-01, price=42.00, stock=8, category=Accessories, createdAt=null, updatedAt=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+------------------------------+-------------------------------+------------+------------
4 | HUB-01 | 42.00 | 8 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:43:41.465017+00 | |
(1 row)created_at and created_by are gone from the UPDATE, and the row keeps its original created_at. The object in memory is still wrong: the DTO says createdAt=null, because the managed instance carries the null copied from the request object, and updatedAt=null for a reason the last section explains. updatable = false protects the row, not the response. Keep it as a guard, and write updates the way dirty checking expects: load the product, copy the request onto it, and let the flush write it.
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = find(id);
mapper.update(request, product);
return mapper.toResponse(product);
}UpdateProductRequest carries name, price, stock and category, and ProductMapper.update copies them with the setters. The PUT endpoint calls this method from here on.
@CreatedBy without Spring Security: AuditorAware and an X-User header
@CreatedBy and @LastModifiedBy take their value from an AuditorAware bean. This project has no authentication yet, so the user name comes from a request header, X-User. Any client can send any value in it; it stands in for a real identity until Chapter 5, which replaces the header with the authenticated user from the SecurityContext.
package com.example.demo.common;
import java.util.Optional;
import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Component
class RequestHeaderAuditorAware implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) {
return Optional.empty();
}
String user = attributes.getRequest().getHeader("X-User");
return StringUtils.hasText(user) ? Optional.of(user) : Optional.empty();
}
}@EnableJpaAuditing has no auditorAwareRef here. AuditingBeanDefinitionRegistrarSupport builds the AuditingHandler bean definition with autowiring by type, setAutowireMode(2) in its bytecode, so the only AuditorAware bean in the context was injected on its own. The runs below show it working. With more than one AuditorAware bean, name the one to use with auditorAwareRef.
Inside a request
The web application, on PostgreSQL:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8132 --spring.profiles.active=postgres
curl -i -s -H 'Content-Type: application/json' -H 'X-User: alice' -d '{"name":"Ergonomic keyboard","sku":"KB-04","price":129.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/productsThe INSERT in the server log, cut to the audit columns:
2026-09-13T17:38:10.561+07:00 DEBUG 44546 --- [demo] [nio-8132-exec-3] org.hibernate.SQL : insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
2026-09-13T17:38:10.562+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:38:10.556859Z]
2026-09-13T17:38:10.562+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [alice]
2026-09-13T17:38:10.563+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:38:10.556859Z]
2026-09-13T17:38:10.563+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind : binding parameter (10:VARCHAR) <- [alice]Two seconds later, a PUT to the product, which got id 5, as bob:
curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Ergonomic keyboard","price":119.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/products/5
docker exec sb-a32-pg psql -U catalog -d catalog -c "select id, sku, price, created_at, updated_at, created_by, updated_by from products where id = 5" id | sku | price | created_at | updated_at | created_by | updated_by
----+-------+--------+-------------------------------+-------------------------------+------------+------------
5 | KB-04 | 119.00 | 2026-09-13 10:38:10.556859+00 | 2026-09-13 10:38:12.694614+00 | alice | bob
(1 row)On create, modifyOnCreate put alice in both user columns; the update replaced only updated_by.
Outside a request: an ApplicationRunner
Every lab run is an ApplicationRunner with no web request on its thread. RequestContextHolder.getRequestAttributes() returned null there, the method returned Optional.empty(), and the first INSERT of the article bound created_by <- [null] and updated_by <- [null] without any error.
A shorter version of the same class is common, and fails there:
@Override
public Optional<String> getCurrentAuditor() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
return Optional.ofNullable(attributes.getRequest().getHeader("X-User"));
}The create2 step with it:
AuditLab: -- create2
AuditLab: !!! org.springframework.dao.InvalidDataAccessApiUsageException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
AuditLab: !!! java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.currentRequestAttributes() throws IllegalStateException when no request is bound, the exception came out of the @PrePersist callback, and Spring translated it into InvalidDataAccessApiUsageException. No SQL was sent. A scheduled job, a message listener or a startup runner that saves an entity would fail the same way, so check for a request with getRequestAttributes() and return Optional.empty().
Optional.empty() on an update keeps the previous auditor
The same product, updated by a PUT without the header:
curl -i -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Ergonomic keyboard","price":109.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/products/5 id | sku | price | created_at | updated_at | created_by | updated_by
----+-------+--------+-------------------------------+-------------------------------+------------+------------
5 | KB-04 | 109.00 | 2026-09-13 10:38:10.556859+00 | 2026-09-13 10:38:14.906025+00 | alice | bob
(1 row)The price and updated_at changed, and updated_by still says bob. In AuditingHandlerSupport, touchAuditor checks whether the auditor is present before it calls setLastModifiedBy; an empty Optional leaves the field as it was loaded. On a new entity that means null; on an existing one it means the previous user is credited with a change they did not make. If anonymous writes are possible, return a fixed value such as "anonymous" or "system" instead of Optional.empty().
A deterministic clock with DateTimeProvider
The default provider reads the system clock, and a test that asserts on created_at cannot know that value in advance. A DateTimeProvider backed by a Clock bean makes the time replaceable:
package com.example.demo.common;
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
class AuditingConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
@Bean
DateTimeProvider auditingDateTimeProvider(Clock clock) {
return () -> Optional.of(Instant.now(clock));
}
}The provider returns an Instant, which the Instant fields take without conversion. To show that the clock is really used, a lab profile replaces it with a fixed one:
package com.example.demo.lab;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("fixed-clock")
class FixedClockConfig {
@Bean
@Primary
Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-01-01T09:00:00Z"), ZoneOffset.UTC);
}
}java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab,fixed-clock --spring.main.web-application-type=none --lab.steps=create2AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-01-01T09:00:00Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-01-01T09:00:00Z]
bind: binding parameter (10:VARCHAR) <- [null]
AuditLab: returned Product[id=9, sku=AC-02, price=34.90, stock=15, createdAt=2026-01-01T09:00:00Z, updatedAt=2026-01-01T09:00:00Z, createdBy=null, updatedBy=null] id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+-------+-------+-------+------------------------+------------------------+------------+------------
9 | AC-02 | 34.90 | 15 | 2026-01-01 09:00:00+00 | 2026-01-01 09:00:00+00 | |
(1 row)Both columns hold exactly the instant the fixed clock returns. The same Clock bean can be injected anywhere else the application needs the time, and Chapter 6 swaps it for a fixed clock in tests.
What bypasses auditing: bulk @Modifying updates and native SQL
The listener only runs for entities Hibernate flushes. Article 27's bulk update and a native UPDATE on the same repository:
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
@Modifying
@Query("update Product p set p.price = p.price * :factor where p.category = :category")
int changePrices(String category, BigDecimal factor);
@Modifying
@Query(value = "update products set stock = stock + :quantity where sku = :sku", nativeQuery = true)
int restockNative(String sku, int quantity);
}Both are called from @Transactional service methods, changePrices and restock. The bulk and native steps, starting from the table after saveAll:
AuditLab: -- bulk
SQL: update products p1_0 set price=(p1_0.price*?) where p1_0.category=?
bind: binding parameter (1:NUMERIC) <- [1.10]
bind: binding parameter (2:VARCHAR) <- [Keyboards]
AuditLab: changePrices -> 2 rows
AuditLab: -- native
SQL: update products set stock = stock + ? where sku = ?
bind: binding parameter (1:INTEGER) <- [5]
bind: binding parameter (2:VARCHAR) <- [MS-01]
AuditLab: restock -> 1 rows id | sku | price | stock | created_at | updated_at | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
1 | KB-01 | 93.39 | 26 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.040412+00 | |
2 | KB-02 | 64.90 | 13 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.037375+00 | |
3 | MS-01 | 24.50 | 8 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 | |
4 | HUB-01 | 39.00 | 10 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:36:02.07131+00 | |
(4 rows)The two keyboards now cost 93.39 and 64.90, the mouse has 8 in stock, and all three updated_at values are exactly what they were before. Neither statement loaded an entity, so there was nothing to flush and no @PreUpdate to call; the SQL has no updated_at in it because nobody wrote it there.

A bulk statement that should count as a modification has to set the column itself, for example set p.price = p.price * :factor, p.updatedAt = :now, and updated_by the same way. The same holds for SQL run by a migration, a database console or another application.
Hibernate @CreationTimestamp and @UpdateTimestamp vs Spring Data auditing
Hibernate has its own pair of annotations that need no configuration and no listener. In the probe project, two entities with an Instant pair each, the second generated by the database:
@Entity
@Table(name = "hibernate_vm_probe")
public static class HibernateVmProbe implements Stamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
// getId, setId, setName and toString
} @Entity
@Table(name = "hibernate_db_probe")
public static class HibernateDbProbe implements Stamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@CreationTimestamp(source = SourceType.DB) private Instant createdAt;
@UpdateTimestamp(source = SourceType.DB) private Instant updatedAt;
// getId, setId, setName and toString
}Both annotations default to source = SourceType.VM, according to javap on the 7.4.5 jar, and neither is deprecated there. Hibernate created both tables with created_at timestamp(6) with time zone not null and the same for updated_at: unlike Spring Data's annotations, these add not null to the generated DDL. Each probe went through five transactions a second apart: persist, rename, load without a change, a JPQL update … set e.name = :name, and a merge of a new object with the same id and null timestamps, reloading the row after the last two. The JVM-generated probe on PostgreSQL, with the SELECT statements after the bulk update and the merge and the id and name bind lines removed:
ProbeRunner: -- persist HibernateVmProbe
SQL: insert into hibernate_vm_probe (created_at,name,updated_at) values (?,?,?)
bind: binding parameter (1:TIMESTAMP_UTC) <- [2026-09-13T10:44:00.612364Z]
bind: binding parameter (3:TIMESTAMP_UTC) <- [2026-09-13T10:44:00.612395Z]
ProbeRunner: HibernateVmProbe[id=1, name=null, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:00.612395Z]
ProbeRunner: -- rename HibernateVmProbe (dirty checking)
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
SQL: update hibernate_vm_probe set name=?,updated_at=? where id=?
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:44:01.878889Z]
ProbeRunner: HibernateVmProbe[id=1, name=renamed, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- load HibernateVmProbe and change nothing
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
ProbeRunner: HibernateVmProbe[id=1, name=renamed, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- bulk update HibernateVmProbe
SQL: update hibernate_vm_probe hvp1_0 set name=?
ProbeRunner: rows=1
ProbeRunner: after bulk, reloaded: HibernateVmProbe[id=1, name=bulk, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- merge a new HibernateVmProbe with the same id and null timestamps
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
core: HHH000502: The [createdAt] property of the [com.example.demo.probe.Entities$HibernateVmProbe] entity was modified, but it won't be updated because the property is immutable.
SQL: update hibernate_vm_probe set name=?,updated_at=? where id=?
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:44:05.702885Z]
ProbeRunner: HibernateVmProbe[id=1, name=merged, createdAt=null, updatedAt=2026-09-13T10:44:05.702885Z]
ProbeRunner: after merge, reloaded: HibernateVmProbe[id=1, name=merged, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:05.702885Z]The database-generated probe, key lines:
ProbeRunner: -- persist HibernateDbProbe
SQL: insert into hibernate_db_probe (created_at,name,updated_at) values (localtimestamp,?,localtimestamp) returning id,created_at,updated_at
ProbeRunner: HibernateDbProbe[id=1, name=null, createdAt=2026-09-13T10:44:05.709053Z, updatedAt=2026-09-13T10:44:05.709053Z]
ProbeRunner: -- rename HibernateDbProbe (dirty checking)
SQL: update hibernate_db_probe set name=?,updated_at=localtimestamp where id=? returning updated_at
ProbeRunner: -- bulk update HibernateDbProbe
SQL: update hibernate_db_probe hdp1_0 set name=?
ProbeRunner: after bulk, reloaded: HibernateDbProbe[id=1, name=bulk, createdAt=2026-09-13T10:44:05.709053Z, updatedAt=2026-09-13T10:44:06.921441Z]
ProbeRunner: -- merge a new HibernateDbProbe with the same id and null timestamps
core: HHH000502: The [createdAt] property of the [com.example.demo.probe.Entities$HibernateDbProbe] entity was modified, but it won't be updated because the property is immutable.
core: HHH000502: The [updatedAt] property of the [com.example.demo.probe.Entities$HibernateDbProbe] entity was modified, but it won't be updated because the property is immutable.
SQL: update hibernate_db_probe set name=?,updated_at=localtimestamp where id=? returning updated_at created_at | id | updated_at | name
-------------------------------+----+-------------------------------+--------
2026-09-13 10:44:00.612364+00 | 1 | 2026-09-13 10:44:05.702885+00 | merged
(1 row)
created_at | id | updated_at | name
-------------------------------+----+-------------------------------+--------
2026-09-13 10:44:05.709053+00 | 1 | 2026-09-13 10:44:10.566356+00 | merged
(1 row)@UpdateTimestampis set on insert. With the JVM source the two columns got two separate readings of the clock, 31 microseconds apart; Spring Data binds one value to both.@CreationTimestampis left out of every UPDATE. The rename sentset name=?,updated_at=?, and the merge with a nullcreatedAtlogged HHH000502 and kept the stored value. It has the protectionupdatable = falsegives Spring Data's field, and the same in-memory null after the merge.SourceType.DBmoves the clock into the statement. Hibernate wrotelocaltimestampinto the INSERT and the UPDATE and read the values back withreturning, with no bind parameter; the stored instant matched the moment of the write.- A bulk update bypasses both.
update hibernate_vm_probe hvp1_0 set name=?leftupdated_atat 10:44:01.878889 and the DB probe at 10:44:06.921441.
Spring Data @CreatedDate / @LastModifiedDate | Hibernate @CreationTimestamp / @UpdateTimestamp | |
|---|---|---|
| Needs | @EnableJpaAuditing and AuditingEntityListener | nothing |
| Last-modified set on insert | yes, same value as created; no with modifyOnCreate = false | yes, a second reading of the clock (VM) or the same localtimestamp (DB) |
| Set on a dirty update | last-modified only | @UpdateTimestamp only |
| Created value in an UPDATE | re-sent; null after a merge unless updatable = false | never, HHH000502 on a merge |
| Value source | DateTimeProvider, replaceable with a Clock bean | JVM clock, or localtimestamp in the SQL with SourceType.DB |
NOT NULL in generated DDL | no | yes |
Bulk @Modifying or JPQL update | bypassed | bypassed |
| Who made the change | @CreatedBy, @LastModifiedBy through AuditorAware | no equivalent |
Hibernate 7.4 also ships @CurrentTimestamp, whose defaults in the jar are source = SourceType.DB and events INSERT, UPDATE and FORCE_INCREMENT. For timestamps alone the Hibernate annotations are the smaller setup. The series keeps Spring Data's, for @CreatedBy and the replaceable clock.
Exposing createdAt and updatedAt in ProductResponse
The response DTO gains the two timestamps, mapped in the service as before:
package com.example.demo.product;
import java.math.BigDecimal;
import java.time.Instant;
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category) {
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category,
Instant createdAt, Instant updatedAt) {
} public ProductResponse toResponse(Product product) {
return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
product.getStock(), product.getCategory());
product.getStock(), product.getCategory(), product.getCreatedAt(), product.getUpdatedAt());
} @PutMapping("/{id}")
public ProductResponse update(@PathVariable Long id, @Valid @RequestBody UpdateProductRequest request) {
return service.update(id, request);
}The user columns stay out of the response; psql shows them. With the final AuditableEntity and AuditingConfig, a POST and a PUT on PostgreSQL:
curl -i -s -H 'Content-Type: application/json' -H 'X-User: alice' -d '{"name":"Webcam 1080p","sku":"AC-05","price":64.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/productsHTTP/1.1 201
Location: http://localhost:8132/api/products/10
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:43:46 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":64.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:46.931802Z"}Jackson 3 wrote each Instant as an ISO-8601 string in UTC with microseconds, the precision the value had. Two seconds later:
curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Webcam 1080p","price":59.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/products/10
curl -i -s http://localhost:8132/api/products/10HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:49 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":59.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:46.931802Z"}
HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:49 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":59.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:49.079407Z"}The updatedAt of a PUT response is one flush behind
The PUT response shows the new price with the old updatedAt; the GET right after it shows 10:43:49.079407Z. update maps the DTO inside the transaction, before the method returns, and @PreUpdate runs only at the flush during the commit, after the DTO already exists. The earlier replace output printed updatedAt=null for the same reason. Flushing before mapping runs the callback first:
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = find(id);
mapper.update(request, product);
repository.flush();
return mapper.toResponse(product);
}curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Webcam 1080p","price":54.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/products/10HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:52 GMT
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":54.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:52.304028Z"}flush() sent the UPDATE inside the method, the listener set updatedAt, and the DTO was built after it. The transaction still commits or rolls back as a whole. For comparison, the replace version of the PUT on the same product, sent with X-User: carol, answered with "createdAt":null,"updatedAt":null while psql showed created_at still at 10:43:46.931802+00, created_by still alice and updated_by now carol.
Auditing annotations at a glance
Every entry comes from the runs above.
| Annotation | Set when | Value source | Notes |
|---|---|---|---|
@CreatedDate | @PrePersist, inside persist, before the INSERT | DateTimeProvider.getNow(); the default returns LocalDateTime.now(), converted to the field type | Instant and LocalDateTime work with the default; OffsetDateTime only with a provider returning OffsetDateTime; a merge copies null into it unless updatable = false |
@LastModifiedDate | @PrePersist with the same value, and @PreUpdate at the flush of a dirty entity | the same provider, one getNow() per callback | null on insert with modifyOnCreate = false; unchanged by a transaction that changes nothing, a bulk @Modifying update or a native UPDATE |
@CreatedBy | @PrePersist | AuditorAware.getCurrentAuditor(), called before getNow() | Optional.empty() leaves it null; currentRequestAttributes() outside a request throws InvalidDataAccessApiUsageException |
@LastModifiedBy | @PrePersist and @PreUpdate | AuditorAware.getCurrentAuditor() | Optional.empty() on an update keeps the previous value |
@CreationTimestamp | INSERT | JVM clock by default, localtimestamp with SourceType.DB | never in an UPDATE; HHH000502 on a merge |
@UpdateTimestamp | INSERT and every dirty UPDATE | JVM clock by default, localtimestamp with SourceType.DB | set on insert with its own clock reading; bypassed by bulk updates |
FAQ
Why are @CreatedDate and @LastModifiedDate null?
Either @EnableJpaAuditing is missing or the entity has no @EntityListeners(AuditingEntityListener.class), on itself or on its @MappedSuperclass. Neither mistake logs a warning at startup: Spring Boot 4.1.1 started normally, Hibernate bound null to all four audit columns, and the only error came from PostgreSQL's NOT NULL constraint on created_at. On an update, a null created_at usually means a detached object was saved; see the question on save() below.
Is @LastModifiedDate set when an entity is created?
Yes, by default. @EnableJpaAuditing has modifyOnCreate = true, and the INSERT bound the same 2026-09-13T10:36:02.071310Z to created_at and updated_at, and the same user to created_by and updated_by. With modifyOnCreate = false both last-modified fields are null on insert, which needs nullable columns.
Does Spring Data JPA auditing support OffsetDateTime?
Only with a DateTimeProvider that returns OffsetDateTime. With the default provider, which returns LocalDateTime, and with one returning Instant, Spring Data JPA 4.1.1 threw IllegalArgumentException: Cannot convert unsupported date type … to java.time.OffsetDateTime. Instant and LocalDateTime fields work with either provider, and Instant in a timestamp with time zone column is the unambiguous choice.
Why does save() set created_at to null on update?
Because save merges an object that has an id, and merge copies every field from it, nulls included. An object built from a request has no createdAt, and the listener only sets the last-modified fields on update, so the UPDATE bound null to created_at and created_by. @Column(updatable = false) removes both columns from the UPDATE; loading the entity and changing it avoids the null in memory as well.
Does a @Modifying bulk update change @LastModifiedDate?
No. The listener runs only for entities Hibernate flushes, and a bulk JPQL or native UPDATE loads none. After update products p1_0 set price=(p1_0.price*?) where p1_0.category=? changed two rows, psql showed both updated_at values unchanged. Set the column in the statement if the change should count. saveAll, by contrast, is audited per entity.
What is the difference between @CreatedDate and @CreationTimestamp?
@CreatedDate is Spring Data's: it needs @EnableJpaAuditing and the listener, takes its value from a replaceable DateTimeProvider, and comes with @CreatedBy. @CreationTimestamp is Hibernate's: no setup, JVM or database clock, and Hibernate keeps the column out of every UPDATE. Its partner @UpdateTimestamp is also set on insert, from a separate clock reading.
How do I fill @CreatedBy without Spring Security?
Provide an AuditorAware<String> bean. This article read an X-User header through RequestContextHolder.getRequestAttributes() and returned Optional.empty() when no request was bound; a single bean is picked up without auditorAwareRef. An empty auditor leaves created_by null on insert and keeps the previous updated_by on update. Chapter 5 takes the user from the SecurityContext instead.
Conclusion
Auditing takes three pieces: @EnableJpaAuditing in a configuration class, AuditingEntityListener on an @MappedSuperclass, and the four annotated fields, plus a migration that gives existing rows a default before the columns become NOT NULL. The listener runs inside Hibernate's own callbacks: @PrePersist during persist, before the INSERT, and @PreUpdate during the flush at commit, only for an entity that changed. It asks AuditorAware for the user and the DateTimeProvider for the time, and sets the fields before the SQL is built. Without the annotation or the listener nothing warns you: the columns are simply null. Instant works with the default provider and stores an unambiguous timestamp with time zone; OffsetDateTime fails unless the provider returns one.
The traps are all about what the listener does not see. A merge of an object built from a request writes null into created_at and created_by unless they are updatable = false; an empty auditor on an update keeps the previous user; a DTO mapped before the flush shows the old updatedAt; bulk @Modifying and native updates leave updated_at untouched. Hibernate's @CreationTimestamp and @UpdateTimestamp protect the creation time on their own, but have no user and no replaceable clock.
That closes Chapter 4. From a DataSource and JdbcClient, the catalogue now has JPA entities and relationships, derived and hand-written queries, paging, transactions, versioned migrations and audit columns. The next article opens Chapter 5 with an overview of Spring Security: authentication versus authorization, the filter chain, and configuring a SecurityFilterChain.