Command Palette

Search for a command to run...

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

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.

A products row with created_at, updated_at, created_by and updated_by filled in, a clock pointing at the two timestamps and a person pointing at the two user columns

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:

src/main/java/com/example/demo/product/ProductService.java
@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:

Text
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]
Text
 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

src/main/java/com/example/demo/common/AuditingConfig.java
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

src/main/java/com/example/demo/common/AuditableEntity.java
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; }
}
  • @MappedSuperclass maps 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 its touchForCreate method carries @PrePersist and touchForUpdate carries @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.
  • @Column names nothing: createdAt becomes created_at through Boot's naming strategy, as the SQL below shows. With ddl-auto=validate the attributes generate no DDL; they record what the migration creates.

Product extends the class and prints the new fields:

src/main/java/com/example/demo/product/Product.java
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:

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

In this article's project products came from V1__create_products_table.sql and already held three rows:

Text
 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:

src/main/resources/db/migration/V2__add_audit_columns_to_products.sql
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);
Bash
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --spring.main.web-application-type=none
Text
2026-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 problem

PostgreSQL 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.

Bash
docker exec sb-a32-pg psql -U catalog -d catalog -c "select installed_rank, version, description, success from flyway_schema_history"
Text
 installed_rank | version |      description      | success
----------------+---------+-----------------------+---------
              1 | 1       | create products table | t
(1 row)

A default fills the existing rows while the column is added:

src/main/resources/db/migration/V2__add_audit_columns_to_products.sql
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:

Text
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:

Bash
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"
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(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:

src/main/java/com/example/demo/lab/AuditLab.java
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);
        }
    }
}
src/main/java/com/example/demo/lab/LabTransactions.java
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:

Bash
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=create

On PostgreSQL, with the stack trace cut to the frames that matter:

Text
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.

How AuditingEntityListener fills the audit columns: on the INSERT path repository.save, EntityManager.persist and the @PrePersist callback, on the UPDATE path a changed field or merge, the flush at commit that finds the entity dirty and the @PreUpdate callback, both entering AuditingEntityListener, then AuditingHandler, AuditorAware first and DateTimeProvider second, the fields set on the entity, and the INSERT and UPDATE statements carrying alice, bob and the bound timestamps

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:

Text
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:

src/main/java/com/example/demo/probe/ProbeConfig.java
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);
        };
    }
}
src/main/java/com/example/demo/probe/Entities.java
    @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:

Text
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:

Text
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

Bash
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"
Text
 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)
  • Instant in timestamp with time zone is an absolute point in time. The JVM ran at UTC+7 and psql in UTC, and the value reads 10:41:50+00, the moment the row was written, whoever reads it.
  • LocalDateTime in timestamp holds the JVM's wall clock, 17:41:51, with no zone. The conversion from LocalDateTime.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 psql session. A PostgreSQL error printed later in this article showed a timestamp with time zone value as 2026-09-13 17:37:28.930979+07, the JVM's zone, while psql showed 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:

Text
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:

Text
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]
Text
          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 typeColumn on PostgreSQLDefault providerProvider returning InstantProvider returning OffsetDateTimeStored value
Instanttimestamp(6) with time zoneworksworksworks2026-09-13 10:41:50.204205+00
LocalDateTimetimestamp(6)worksworksworks2026-09-13 17:41:51.431023, JVM wall clock
OffsetDateTimetimestamp(6) with time zoneIllegalArgumentExceptionIllegalArgumentExceptionworks2026-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:

src/main/java/com/example/demo/common/AuditingConfig.java
@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(modifyOnCreate = false) 
class AuditingConfig {
}

The create2 step, on PostgreSQL:

Text
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 constraint

created_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:

Text
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]
Text
 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:

Text
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:

Text
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]
Text
 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:

src/main/java/com/example/demo/product/ProductService.java
    @Transactional
    public ProductResponse replace(Long id, CreateProductRequest request) {
        Product product = mapper.toProduct(request);
        product.setId(id);
        return mapper.toResponse(repository.save(product));
    }
src/main/java/com/example/demo/product/Product.java
    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:

Text
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 constraint

The 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:

src/main/java/com/example/demo/common/AuditableEntity.java
    @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:

Text
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]
Text
 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.

src/main/java/com/example/demo/product/ProductService.java
    @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.

src/main/java/com/example/demo/common/RequestHeaderAuditorAware.java
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:

Bash
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/products

The INSERT in the server log, cut to the audit columns:

Text
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:

Bash
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"
Text
 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:

src/main/java/com/example/demo/common/RequestHeaderAuditorAware.java
    @Override
    public Optional<String> getCurrentAuditor() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        return Optional.ofNullable(attributes.getRequest().getHeader("X-User"));
    }

The create2 step with it:

Text
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:

Bash
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
Text
 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:

src/main/java/com/example/demo/common/AuditingConfig.java
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:

src/main/java/com/example/demo/lab/FixedClockConfig.java
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);
    }
}
Bash
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=create2
Text
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-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]
Text
 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:

src/main/java/com/example/demo/product/ProductRepository.java
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:

Text
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
Text
 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.

Side by side on PostgreSQL 18.6: loading KB-01, changing its price and committing ran @PreUpdate and moved updated_at from 10:35:23.188558 to 10:36:50.39416, while the @Modifying JPQL update and the native UPDATE went straight to the database and left updated_at at 10:36:55.040412, 10:36:55.037375 and 10:35:23.188558

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:

src/main/java/com/example/demo/probe/Entities.java
    @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
    }
src/main/java/com/example/demo/probe/Entities.java
    @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:

Text
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:

Text
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
Text
          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)
  • @UpdateTimestamp is 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.
  • @CreationTimestamp is left out of every UPDATE. The rename sent set name=?,updated_at=?, and the merge with a null createdAt logged HHH000502 and kept the stored value. It has the protection updatable = false gives Spring Data's field, and the same in-memory null after the merge.
  • SourceType.DB moves the clock into the statement. Hibernate wrote localtimestamp into the INSERT and the UPDATE and read the values back with returning, 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=? left updated_at at 10:44:01.878889 and the DB probe at 10:44:06.921441.
Spring Data @CreatedDate / @LastModifiedDateHibernate @CreationTimestamp / @UpdateTimestamp
Needs@EnableJpaAuditing and AuditingEntityListenernothing
Last-modified set on insertyes, same value as created; no with modifyOnCreate = falseyes, a second reading of the clock (VM) or the same localtimestamp (DB)
Set on a dirty updatelast-modified only@UpdateTimestamp only
Created value in an UPDATEre-sent; null after a merge unless updatable = falsenever, HHH000502 on a merge
Value sourceDateTimeProvider, replaceable with a Clock beanJVM clock, or localtimestamp in the SQL with SourceType.DB
NOT NULL in generated DDLnoyes
Bulk @Modifying or JPQL updatebypassedbypassed
Who made the change@CreatedBy, @LastModifiedBy through AuditorAwareno 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:

src/main/java/com/example/demo/product/ProductResponse.java
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) { 
}
src/main/java/com/example/demo/product/ProductMapper.java
    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()); 
    }
src/main/java/com/example/demo/product/ProductController.java
    @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:

Bash
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/products
Text
HTTP/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:

Bash
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/10
Text
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: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:

src/main/java/com/example/demo/product/ProductService.java
    @Transactional
    public ProductResponse update(Long id, UpdateProductRequest request) {
        Product product = find(id);
        mapper.update(request, product);
        repository.flush(); 
        return mapper.toResponse(product);
    }
Bash
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/10
Text
HTTP/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.

AnnotationSet whenValue sourceNotes
@CreatedDate@PrePersist, inside persist, before the INSERTDateTimeProvider.getNow(); the default returns LocalDateTime.now(), converted to the field typeInstant 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 entitythe same provider, one getNow() per callbacknull on insert with modifyOnCreate = false; unchanged by a transaction that changes nothing, a bulk @Modifying update or a native UPDATE
@CreatedBy@PrePersistAuditorAware.getCurrentAuditor(), called before getNow()Optional.empty() leaves it null; currentRequestAttributes() outside a request throws InvalidDataAccessApiUsageException
@LastModifiedBy@PrePersist and @PreUpdateAuditorAware.getCurrentAuditor()Optional.empty() on an update keeps the previous value
@CreationTimestampINSERTJVM clock by default, localtimestamp with SourceType.DBnever in an UPDATE; HHH000502 on a merge
@UpdateTimestampINSERT and every dirty UPDATEJVM clock by default, localtimestamp with SourceType.DBset 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.

Related Posts

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1: SLF4J as the facade and Logback as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.

[Spring Boot Basics] Unit Testing in Spring Boot: JUnit 6, AssertJ and Mockito for the Service Layer

Unit testing the service layer of a Spring Boot 4.1.1 application with JUnit, AssertJ and Mockito: what a unit test replaces, the Gradle test task and its report, a new test instance per method proven by identity, @Nested and parameterized display names in JUnit 6, the BigDecimal isEqualTo trap and soft assertions with their failure messages, @Mock with constructor injection versus @InjectMocks passing null, stubbing, verify and ArgumentCaptor, UnnecessaryStubbingException and PotentialStubbingProblem under strict stubs, a fixed Clock, and loading Mockito as a -javaagent to remove the self-attaching warning.

[Spring Boot Basics] Testing with Spring Boot: @SpringBootTest, @WebMvcTest with MockMvcTester and @DataJpaTest

Spring Boot 4.1.1 testing with spring-test and spring-security-test: the per-feature test starters, the Boot 3 imports that no longer compile and @MockBean replaced by @MockitoBean, measured startup times and bean counts for @WebMvcTest, @DataJpaTest and @SpringBootTest, MockMvcTester with bodyJson assertions for 200, 201, 404, 409, 422 and 500 ProblemDetail responses, why the web slice runs Boot default security until you @Import your SecurityFilterChain, @WithMockUser versus jwt() with 401, 403 and 201, @DataJpaTest with a replaced embedded database, rollback per test and the first-level cache trap, @SpringBootTest in MOCK and RANDOM_PORT with RestTestClient, the @Transactional rollback that a real server thread escapes, @TestConfiguration, @ActiveProfiles and the test context cache.

[Spring Boot Basics] Pagination and Sorting in Spring Boot: Pageable, Sort and Paged API Responses

Pagination and sorting in Spring Boot 4.1.1 with Spring Data JPA, on H2 and PostgreSQL: Sort with ignoreCase, nullsFirst and nullsLast and the SQL each produced on both databases, the deprecated TypedSort, zero-based PageRequest, Page vs Slice vs List and the count query and extra row behind each, when Spring Data skips the count, @Query and native queries with Pageable, the inflated totalElements of a paged JOIN FETCH, a Pageable controller with @PageableDefault, max-page-size and one-indexed parameters, the PageImpl serialization warning, PagedModel versus a PageResponse record, a 400 ProblemDetail for an unknown sort property, and OFFSET versus keyset scrolling with Window.