JPA Auditing
JPA auditing in Spring Data automatically records who created each database row and when, and who changed it last and when. You add four annotated fields to an entity, and Spring fills them in on every insert and update, with no code in your services. When a price is wrong or a record disappears, these columns are the first thing support and auditors look at, and doing it by hand in every service is easy to forget.
This tutorial explains how Spring Data JPA auditing works and how to enable it with @EnableJpaAuditing. It covers the @CreatedBy, @CreatedDate, @LastModifiedBy and @LastModifiedDate annotations, a reusable Auditable base class, and filling in the user from Spring Security with AuditorAware and the time from a Clock. It then builds a product catalog on Spring Boot 4.1, shows the real failures behind the most common pitfalls, and tests the audit columns with a controllable clock.
1. What Is JPA Auditing?
Auditing here means keeping four facts on every row: who created it, when it was created, who last changed it and when that was. Spring Data JPA can fill these columns for you. You mark entity fields with Spring Data's audit annotations, register an entity listener (a class JPA calls just before it inserts or updates a row), and tell Spring how to find the current user and the current time.
It records only the latest change, not a full history. If you need every past version of a row, you need a history tool such as Hibernate Envers on top (section 2). The Spring Data JPA overview covers the repository basics this page builds on.
2. Spring Data JPA Auditing vs Other Approaches
| Approach | Who | When | History of changes | Notes |
|---|---|---|---|---|
| Setting fields by hand in services | Yes, if you remember | Yes, if you remember | No | Easy to forget on one code path |
Hibernate @CreationTimestamp / @UpdateTimestamp | No | Yes | No | Timestamps only, set by Hibernate |
| Spring Data JPA auditing | Yes, via AuditorAware | Yes, via DateTimeProvider | No | Works with any JPA entity and your security context |
| Hibernate Envers | With a custom revision entity | Yes | Yes, a full copy per change | Extra audit tables; use for compliance trails |
3. How JPA Auditing Works
- Your service saves an entity or changes a loaded one. The entity has
@EntityListeners(AuditingEntityListener.class). - Just before the
INSERT, JPA fires its@PrePersistcallback. Just before anUPDATE, it fires@PreUpdate.AuditingEntityListenerreceives both. - The listener asks your
AuditorAwarebean for the current user and yourDateTimeProviderbean for the current time. - On insert it fills all four fields. On update it fills only the two "last modified" fields.
- Hibernate writes the row with those values.
Everything hangs on the JPA callbacks. Anything that writes to the database without loading entities, such as a bulk @Query update or plain SQL, is invisible to auditing (section 7.3).
4. Setup: Enabling JPA Auditing
Auditing is part of Spring Data JPA, so the JPA starter is enough. The example also uses Spring Security to know who is logged in, and H2 as its database:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot does not enable auditing by itself. Add @EnableJpaAuditing to a configuration class of its own, not to the main application class (section 7.2 shows why):
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider", dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingConfig {
// AuditorAware and DateTimeProvider beans, see sections 5.3 and 5.4
}
@DataJpaTest now lives in org.springframework.boot.data.jpa.test.autoconfigure (from spring-boot-starter-data-jpa-test), and TestEntityManager in org.springframework.boot.jpa.test.autoconfigure.5. Core Concepts
5.1 The Four Audit Annotations
| Annotation | Set on insert | Set on update | Typical type |
|---|---|---|---|
@CreatedBy | Yes | No | String (username) or a user id |
@CreatedDate | Yes | No | Instant |
@LastModifiedBy | Yes (because modifyOnCreate defaults to true) | Yes | Same type as @CreatedBy |
@LastModifiedDate | Yes (same reason) | Yes | Instant |
All four come from org.springframework.data.annotation. Use Instant for the dates: it is a single moment in UTC, so there is no confusion about time zones between servers.
5.2 A Reusable Auditable Base Class
Put the four fields in a @MappedSuperclass, a class whose fields are mapped into every entity that extends it, without a table of its own. The @EntityListeners annotation goes on it too, so no entity can forget the listener:
package com.javabytechie.shop.product;
import java.time.Instant;
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;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
// Shared audit columns: every entity that extends this class gets them
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedBy
@Column(nullable = false, updatable = false, length = 100)
private String createdBy;
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedBy
@Column(nullable = false, length = 100)
private String lastModifiedBy;
@LastModifiedDate
@Column(nullable = false)
private Instant lastModifiedAt;
public String getCreatedBy() { return createdBy; }
public Instant getCreatedAt() { return createdAt; }
public String getLastModifiedBy() { return lastModifiedBy; }
public Instant getLastModifiedAt() { return lastModifiedAt; }
}
updatable = false on the "created" columns keeps them out of every UPDATE statement. That protects them from being overwritten by mistake (section 7.4).
5.3 Who Made the Change: AuditorAware
AuditorAware<T> has one method, getCurrentAuditor(), which returns the current user as an Optional. Reading it from Spring Security's context ties auditing to the real login. A fallback such as "system" covers scheduled jobs and startup code, which run without a logged-in user:
@Bean
public AuditorAware<String> auditorProvider() {
return () -> {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
return Optional.of("system");
}
return Optional.of(authentication.getName());
};
}
The Spring Security introduction explains the security context that this reads from.
5.4 When It Happened: DateTimeProvider and Clock
By default, Spring takes the time from the system clock. A DateTimeProvider bean that reads an injected java.time.Clock lets tests replace the clock and check exact timestamps:
@Bean
public Clock clock() {
return Clock.systemUTC();
}
@Bean
public DateTimeProvider auditingDateTimeProvider(Clock clock) {
return () -> Optional.of(clock.instant());
}
5.5 @EnableJpaAuditing Options
| Attribute | Default | Purpose |
|---|---|---|
auditorAwareRef | Empty: a single AuditorAware bean is picked up | Bean name of the AuditorAware to use |
dateTimeProviderRef | Empty: a single DateTimeProvider bean is picked up, otherwise the system time is used | Bean name of a custom DateTimeProvider |
modifyOnCreate | true | Also fill the "last modified" fields when a row is created |
setDates | true | Fill the date fields at all; set to false to record only users |
With exactly one bean of each type, both references are optional. The example names them anyway, so it is obvious which beans auditing uses.
6. A Complete Example: Auditing a Product Catalog
Two catalog managers, Priya and Arjun, create products and change prices. Every product extends Auditable, so its four audit columns are filled automatically. The bulk "raise all prices in a category" operation sets its audit columns explicitly, because bulk updates skip the listener. An unknown product returns 404 as a ProblemDetail.
6.1 Configuration
package com.javabytechie.shop.config;
import java.time.Clock;
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.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
// Kept out of ShopApplication so test slices such as @WebMvcTest don't need JPA
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider", dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingConfig {
static final String SYSTEM_USER = "system";
@Bean
public Clock clock() {
return Clock.systemUTC();
}
// Who is making the change: the logged-in user, or "system" for jobs without one
@Bean
public AuditorAware<String> auditorProvider() {
return () -> {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
return Optional.of(SYSTEM_USER);
}
return Optional.of(authentication.getName());
};
}
// When the change happens: read from the Clock bean, so tests can fix the time
@Bean
public DateTimeProvider auditingDateTimeProvider(Clock clock) {
return () -> Optional.of(clock.instant());
}
}
package com.javabytechie.shop.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// Two catalog managers, so the audit columns show who did what
@Bean
public UserDetailsService users(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("priya").password(encoder.encode("priya-pass")).roles("CATALOG").build(),
User.withUsername("arjun").password(encoder.encode("arjun-pass")).roles("CATALOG").build());
}
}
spring.application.name=shop
spring.datasource.url=jdbc:h2:mem:shop
spring.jpa.open-in-view=false
# Show the SQL, including the audit columns Spring fills in
logging.level.org.hibernate.SQL=DEBUG
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
6.2 Entity and Repository
package com.javabytechie.shop.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
@Entity
public class Product extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String sku;
@Column(nullable = false)
private String name;
@Column(nullable = false, length = 30)
private String category;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Version
private long version;
protected Product() {
}
public Product(String sku, String name, String category, BigDecimal price) {
this.sku = sku;
this.name = name;
this.category = category;
this.price = price;
}
public void changePrice(BigDecimal newPrice) {
this.price = newPrice;
}
public Long getId() { return id; }
public String getSku() { return sku; }
public String getName() { return name; }
public String getCategory() { return category; }
public BigDecimal getPrice() { return price; }
public long getVersion() { return version; }
}
package com.javabytechie.shop.product;
import java.time.Instant;
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;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
// Shared audit columns: every entity that extends this class gets them
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedBy
@Column(nullable = false, updatable = false, length = 100)
private String createdBy;
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedBy
@Column(nullable = false, length = 100)
private String lastModifiedBy;
@LastModifiedDate
@Column(nullable = false)
private Instant lastModifiedAt;
public String getCreatedBy() { return createdBy; }
public Instant getCreatedAt() { return createdAt; }
public String getLastModifiedBy() { return lastModifiedBy; }
public Instant getLastModifiedAt() { return lastModifiedAt; }
}
package com.javabytechie.shop.product;
import java.math.BigDecimal;
import java.time.Instant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
// A bulk update skips entity listeners, so the audit columns are set here by hand
@Modifying(clearAutomatically = true)
@Query("""
update Product p
set p.price = p.price * :factor,
p.lastModifiedBy = :user,
p.lastModifiedAt = :now,
p.version = p.version + 1
where p.category = :category
""")
int raisePrices(@Param("category") String category, @Param("factor") BigDecimal factor,
@Param("user") String user, @Param("now") Instant now);
}
The schema is generated by Hibernate here to keep the example short. In production, create the audit columns with a migration, as shown in the database migration tutorial.
6.3 Service, Controller and DTOs
package com.javabytechie.shop.product;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository productRepository;
private final AuditorAware<String> auditorAware;
private final Clock clock;
public ProductService(ProductRepository productRepository, AuditorAware<String> auditorAware, Clock clock) {
this.productRepository = productRepository;
this.auditorAware = auditorAware;
this.clock = clock;
}
@Transactional
public ProductResponse create(CreateProductRequest request) {
Product product = productRepository.save(
new Product(request.sku(), request.name(), request.category(), request.price()));
return ProductResponse.from(product);
}
@Transactional(readOnly = true)
public ProductResponse findById(Long id) {
return ProductResponse.from(load(id));
}
// Load, change, let dirty checking write the UPDATE: the listener fills the "last modified" columns
@Transactional
public ProductResponse changePrice(Long id, BigDecimal newPrice) {
Product product = load(id);
product.changePrice(newPrice);
productRepository.flush();
return ProductResponse.from(product);
}
@Transactional
public int raisePrices(String category, int percent) {
BigDecimal factor = BigDecimal.ONE.add(BigDecimal.valueOf(percent).divide(BigDecimal.valueOf(100), 4, RoundingMode.UNNECESSARY));
String user = auditorAware.getCurrentAuditor().orElseThrow();
int updated = productRepository.raisePrices(category, factor, user, clock.instant());
log.info("{} raised {} prices in {} by {}%", user, updated, category, percent);
return updated;
}
private Product load(Long id) {
return productRepository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
}
package com.javabytechie.shop.product;
import java.net.URI;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<ProductResponse> create(@RequestBody CreateProductRequest request) {
ProductResponse product = productService.create(request);
return ResponseEntity.created(URI.create("/api/products/" + product.id())).body(product);
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
return productService.findById(id);
}
@PutMapping("/{id}/price")
public ProductResponse changePrice(@PathVariable Long id, @RequestBody PriceChangeRequest request) {
return productService.changePrice(id, request.price());
}
@PostMapping("/price-increase")
public Map<String, Integer> raisePrices(@RequestParam String category, @RequestParam int percent) {
return Map.of("updated", productService.raisePrices(category, percent));
}
}
package com.javabytechie.shop.product;
import java.math.BigDecimal;
import java.time.Instant;
public record ProductResponse(Long id, String sku, String name, BigDecimal price,
String createdBy, Instant createdAt,
String lastModifiedBy, Instant lastModifiedAt) {
static ProductResponse from(Product product) {
return new ProductResponse(product.getId(), product.getSku(), product.getName(), product.getPrice(),
product.getCreatedBy(), product.getCreatedAt(),
product.getLastModifiedBy(), product.getLastModifiedAt());
}
}
package com.javabytechie.shop.product;
import java.math.BigDecimal;
public record CreateProductRequest(String sku, String name, String category, BigDecimal price) {
}
package com.javabytechie.shop.product;
import java.math.BigDecimal;
public record PriceChangeRequest(BigDecimal price) {
}
package com.javabytechie.shop.product;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product not found: " + id);
}
}
package com.javabytechie.shop.product;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ProductExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail notFound(ProductNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
6.4 Run It
Priya creates a keyboard. All four audit fields name her and share one timestamp:
$ curl -u priya:priya-pass -X POST localhost:8080/api/products -H 'Content-Type: application/json' \
-d '{"sku":"KB-100","name":"Mechanical Keyboard","category":"keyboards","price":89.00}'
HTTP/1.1 201
{"id":1,"sku":"KB-100","name":"Mechanical Keyboard","price":89.00,"createdBy":"priya","createdAt":"2026-10-25T17:32:24.577353Z","lastModifiedBy":"priya","lastModifiedAt":"2026-10-25T17:32:24.577353Z"}
Two seconds later, Arjun lowers the price. Only the "last modified" fields change:
$ curl -u arjun:arjun-pass -X PUT localhost:8080/api/products/1/price -H 'Content-Type: application/json' \
-d '{"price":84.50}'
HTTP/1.1 200
{"id":1,"sku":"KB-100","name":"Mechanical Keyboard","price":84.50,"createdBy":"priya","createdAt":"2026-10-25T17:32:24.577353Z","lastModifiedBy":"arjun","lastModifiedAt":"2026-10-25T17:32:26.798271Z"}
The SQL shows the listener at work. The INSERT includes all four audit columns, while the UPDATE leaves out created_at and created_by because of updatable = false:
org.hibernate.SQL : insert into product (category,created_at,created_by,last_modified_at,last_modified_by,name,price,sku,version,id) values (?,?,?,?,?,?,?,?,?,default)
org.hibernate.SQL : update product set category=?,last_modified_at=?,last_modified_by=?,name=?,price=?,sku=?,version=? where id=? and version=?
Sending the same price again changes nothing, so Hibernate sends no UPDATE and lastModifiedAt stays the same. The bulk increase records Arjun because the query sets the columns itself:
# The same price again: no UPDATE, lastModifiedAt unchanged
$ curl -u arjun:arjun-pass -X PUT localhost:8080/api/products/1/price -H 'Content-Type: application/json' -d '{"price":84.50}'
{"id":1,...,"lastModifiedBy":"arjun","lastModifiedAt":"2026-10-25T17:32:26.798271Z"}
# Raise every keyboard price by 10%
$ curl -u arjun:arjun-pass -X POST 'localhost:8080/api/products/price-increase?category=keyboards&percent=10'
{"updated":2}
$ curl -u priya:priya-pass localhost:8080/api/products/2
{"id":2,"sku":"KB-200","name":"Compact Keyboard","price":64.90,"createdBy":"priya","createdAt":"2026-10-25T17:32:24.679703Z","lastModifiedBy":"arjun","lastModifiedAt":"2026-10-25T17:32:27.974364Z"}
$ curl -i -u priya:priya-pass localhost:8080/api/products/99
HTTP/1.1 404
{"detail":"Product not found: 99","instance":"/api/products/99","status":404,"title":"Not Found"}
7. Common Pitfalls and How to Fix Them
7.1 Audit Columns Stay Null
Without @EnableJpaAuditing, or without @EntityListeners(AuditingEntityListener.class) on the entity, nothing fills the fields. With nullable = false on the columns, the first insert fails, which is better than silently saving nulls:
POST /api/products -> HTTP/1.1 500
org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value for entity com.javabytechie.shop.product.Product.createdAt
How to fix it: add @EnableJpaAuditing to a configuration class, and put @EntityListeners on the shared base class so no entity can miss it.
7.2 "JPA Metamodel Must Not Be Empty" in @WebMvcTest
With @EnableJpaAuditing on the main application class, every test slice loads it, including @WebMvcTest, which has no JPA at all:
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaAuditingHandler': Cannot resolve reference to bean 'jpaMappingContext' while setting constructor argument
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaMappingContext': JPA metamodel must not be empty
Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty
How to fix it: move @EnableJpaAuditing to its own @Configuration class, like AuditingConfig. With that change, the same controller slice test passed.
7.3 Bulk Updates Skip Auditing
A JPQL update query changes rows directly in the database. No entity is loaded, so no listener runs. A bulk price increase without audit columns changed the price but left lastModifiedAt at its old value. The record looked as if nobody had touched it:
@Modifying(clearAutomatically = true)
@Query("update Product p set p.price = p.price * :factor where p.category = :category")
int raisePricesWithoutAudit(@Param("category") String category, @Param("factor") BigDecimal factor);
// Test output: price 97.90, lastModifiedAt unchanged = true
How to fix it: set the audit columns in the query yourself, using the same AuditorAware and Clock, as ProductRepository.raisePrices does. Increment @Version too, so optimistic locking still notices the change.
7.4 Created Fields Wiped by Saving a Detached Copy
Mapping a request body straight onto a new entity with an existing id, then calling save, merges that copy into the database. The copy's createdBy and createdAt are null. Without updatable = false, the merge overwrote them:
# Product created by arjun, then a copy with the same id and version saved from "request data"
created columns WITHOUT updatable = false: createdBy=null createdAt=null lastModifiedBy=arjun
created columns WITH updatable = false: createdBy=arjun createdAt=2026-10-25T17:46:12.310473Z lastModifiedBy=arjun
How to fix it: mark the created columns updatable = false, which kept them intact in the same test, and prefer load, change, save in your services, as changePrice does.
7.5 lastModifiedAt Doesn't Change
If a request sets a field to the value it already has, Hibernate's dirty checking finds nothing to update and sends no UPDATE. No update means no @PreUpdate, so the "last modified" fields stay as they were. That is usually what you want. Only real changes count as modifications.
7.6 createdBy Is Null for Background Jobs
A scheduled job or message listener has no logged-in user. An AuditorAware that returns Optional.empty() then leaves createdBy null. How to fix it: return a clear fallback such as "system" (or the job's name), as the example does. If a job acts on behalf of a user, set that user in the security context before it runs.
8. Testing JPA Auditing
Audit columns depend on two things a test normally can't control: the current user and the current time. The test below controls both. A MutableClock starts at a fixed instant and can be moved forward, and a small as(user, action) helper puts a user in the security context. @DataJpaTest doesn't load your configuration classes, so AuditingConfig is imported explicitly, and the test clock replaces the real one with @Primary:
package com.javabytechie.shop.product;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.function.Supplier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import com.javabytechie.shop.config.AuditingConfig;
@DataJpaTest
@Import({AuditingConfig.class, ProductService.class, ProductAuditingTest.FixedTime.class})
class ProductAuditingTest {
static final Instant T0 = Instant.parse("2026-10-25T09:00:00Z");
@TestConfiguration
static class FixedTime {
@Bean
@Primary
MutableClock testClock() {
return new MutableClock(T0);
}
}
@Autowired
ProductService productService;
@Autowired
ProductRepository productRepository;
@Autowired
TestEntityManager entityManager;
@Autowired
MutableClock clock;
@BeforeEach
void resetClock() {
// The clock bean is shared by all tests in the cached context
clock.set(T0);
}
@AfterEach
void logout() {
SecurityContextHolder.clearContext();
}
// Runs the action as the given user, like a logged-in request would
private <T> T as(String username, Supplier<T> action) {
SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
username, "n/a", AuthorityUtils.createAuthorityList("ROLE_CATALOG")));
try {
return action.get();
} finally {
SecurityContextHolder.clearContext();
}
}
private Product reload(Long id) {
entityManager.flush();
entityManager.clear();
return productRepository.findById(id).orElseThrow();
}
private Long createKeyboardAsPriya() {
return as("priya", () -> productService.create(
new CreateProductRequest("KB-100", "Mechanical Keyboard", "keyboards", new BigDecimal("89.00")))).id();
}
@Test
void creatingFillsAllFourAuditFields() {
Long id = createKeyboardAsPriya();
Product product = reload(id);
assertThat(product.getCreatedBy()).isEqualTo("priya");
assertThat(product.getCreatedAt()).isEqualTo(T0);
// modifyOnCreate = true (the default): a new row also counts as modified
assertThat(product.getLastModifiedBy()).isEqualTo("priya");
assertThat(product.getLastModifiedAt()).isEqualTo(T0);
}
@Test
void updatingChangesOnlyTheLastModifiedFields() {
Long id = createKeyboardAsPriya();
assertThat(reload(id).getLastModifiedBy()).isEqualTo("priya");
clock.advance(Duration.ofHours(2));
as("arjun", () -> productService.changePrice(id, new BigDecimal("84.50")));
Product product = reload(id);
assertThat(product.getLastModifiedBy()).isEqualTo("arjun");
assertThat(product.getLastModifiedAt()).isEqualTo(T0.plus(Duration.ofHours(2)));
assertThat(product.getCreatedBy()).isEqualTo("priya");
assertThat(product.getCreatedAt()).isEqualTo(T0);
}
@Test
void savingWithoutChangesDoesNotTouchLastModified() {
Long id = createKeyboardAsPriya();
clock.advance(Duration.ofHours(2));
as("arjun", () -> productService.changePrice(id, new BigDecimal("89.00"))); // same price as before
Product product = reload(id);
assertThat(product.getLastModifiedBy()).isEqualTo("priya");
assertThat(product.getLastModifiedAt()).isEqualTo(T0);
}
@Test
void changesWithoutALoggedInUserAreRecordedAsSystem() {
Long id = productService.create(
new CreateProductRequest("MS-200", "Wireless Mouse", "mice", new BigDecimal("25.50"))).id();
assertThat(reload(id).getCreatedBy()).isEqualTo("system");
}
@Test
void bulkPriceIncreaseRecordsWhoAndWhen() {
Long id = createKeyboardAsPriya();
clock.advance(Duration.ofDays(1));
int updated = as("arjun", () -> productService.raisePrices("keyboards", 10));
Product product = reload(id);
assertThat(updated).isEqualTo(1);
assertThat(product.getPrice()).isEqualByComparingTo("97.90");
assertThat(product.getLastModifiedBy()).isEqualTo("arjun");
assertThat(product.getLastModifiedAt()).isEqualTo(T0.plus(Duration.ofDays(1)));
}
}
package com.javabytechie.shop.product;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
// A clock the test can move forward, so audit timestamps are predictable
class MutableClock extends Clock {
private Instant now;
MutableClock(Instant start) {
this.now = start;
}
void set(Instant instant) {
now = instant;
}
void advance(Duration duration) {
now = now.plus(duration);
}
@Override
public Instant instant() {
return now;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
reload flushes and clears the persistence context before reading, so the assertions see what is really in the database, not cached objects. A separate test checks the same behaviour through the REST API with real HTTP Basic logins. The Spring Boot testing tutorial explains the test annotations in more detail:
package com.javabytechie.shop.product;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;
import com.jayway.jsonpath.JsonPath;
@SpringBootTest
@AutoConfigureMockMvc
class ProductApiAuditTest {
@Autowired
MockMvcTester mvc;
@Test
void auditFieldsFollowTheLoggedInUser() throws Exception {
MvcTestResult created = mvc.post().uri("/api/products").with(httpBasic("priya", "priya-pass"))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sku\":\"HD-300\",\"name\":\"Wireless Headset\",\"category\":\"audio\",\"price\":149.00}")
.exchange();
assertThat(created).hasStatus(HttpStatus.CREATED)
.bodyJson().extractingPath("$.createdBy").isEqualTo("priya");
Integer id = JsonPath.read(created.getResponse().getContentAsString(), "$.id");
MvcTestResult updated = mvc.put().uri("/api/products/{id}/price", id).with(httpBasic("arjun", "arjun-pass"))
.contentType(MediaType.APPLICATION_JSON).content("{\"price\":139.00}")
.exchange();
assertThat(updated).hasStatusOk();
assertThat(updated).bodyJson().extractingPath("$.createdBy").isEqualTo("priya");
assertThat(updated).bodyJson().extractingPath("$.lastModifiedBy").isEqualTo("arjun");
}
@Test
void unknownProductReturns404() {
assertThat(mvc.get().uri("/api/products/999").with(httpBasic("priya", "priya-pass")))
.hasStatus(HttpStatus.NOT_FOUND);
}
}
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.product.ProductAuditingTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.product.ProductApiAuditTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
9. When to Use JPA Auditing
| Good fit | Poor fit |
|---|---|
| "Who changed this last, and when?" on business records such as products, orders and customers | A full history of every change; use Hibernate Envers or an event log |
| Showing "last updated by" in admin screens | Data written mostly by bulk SQL or other systems, which bypass entity listeners |
| Sorting or filtering by creation or update time | Legal audit trails that must be tamper-proof; use database triggers or a separate audit store |
| Debugging "this price changed and nobody knows why" | Tracking reads as well as writes |
10. Best Practices
- Put the audit fields in one @MappedSuperclass with
@EntityListeners(AuditingEntityListener.class), and extend it everywhere. - Enable auditing in its own configuration class, not on the main application class.
- Use Instant for audit dates to avoid time-zone mix-ups.
- Mark created columns updatable = false and all four columns
nullable = false. - Read the user from the security context, with an explicit fallback like
"system"for jobs. - Supply the time from a Clock through a
DateTimeProvider, so tests can check exact values. - Set audit columns yourself in bulk updates, and increment the version column too.
- Update entities by loading and changing them, not by saving detached copies built from requests.
- Create the audit columns in your migrations rather than relying on Hibernate's schema generation.
- Test both the insert and the update, checking that created fields stay unchanged after an update.
11. Summary
Spring Data JPA auditing fills @CreatedBy, @CreatedDate, @LastModifiedBy and @LastModifiedDate automatically, through JPA's insert and update callbacks. Enable it with @EnableJpaAuditing in a separate configuration class. Put the four fields in a @MappedSuperclass with AuditingEntityListener, supply the user with an AuditorAware that reads Spring Security's context, and supply the time with a DateTimeProvider backed by a Clock. Protect the created columns with updatable = false, and remember that bulk updates and plain SQL bypass auditing, so set those columns yourself. The main recommendation: make every entity extend one auditable base class, and test the audit fields with a fixed clock and a known user, both on insert and on update.
Frequently Asked Questions (FAQ)
Q1. Why are my @CreatedDate and @LastModifiedDate fields null?
Auditing is not active for that entity. Check that a configuration class has @EnableJpaAuditing and that the entity, or its @MappedSuperclass, has @EntityListeners(AuditingEntityListener.class). Also check that the row was written through JPA and not by a bulk query or plain SQL.
Q2. Why is @CreatedBy null even though auditing works for dates?
Dates need nothing extra, but users need an AuditorAware bean. If it is missing, or returns Optional.empty() because nobody is logged in, @CreatedBy stays null. Add an AuditorAware that reads the security context and returns a fallback such as "system".
Q3. @CreatedDate vs @CreationTimestamp: what is the difference?
@CreatedDate belongs to Spring Data and is filled by AuditingEntityListener, using your DateTimeProvider. @CreationTimestamp belongs to Hibernate and is filled by Hibernate itself. Spring Data auditing also records the user and lets tests control the time, so it is the better fit when you need both.
Q4. How do I get the current user for @CreatedBy?
Implement AuditorAware<String> and read SecurityContextHolder.getContext().getAuthentication(). Return its name for logged-in users, and a fallback for anonymous calls and background jobs. You can also return a user id or a user entity, as long as the field type matches.
Q5. Does Spring Data JPA auditing work with bulk updates?
No. @Modifying queries and native SQL update the database directly, without loading entities, so no entity listener runs. Set the "last modified" columns in the query yourself, using values from your AuditorAware and Clock.
Q6. Should I use LocalDateTime or Instant for audit dates?
Use Instant. It is an exact moment in UTC, so values from servers in different time zones compare correctly. LocalDateTime has no time zone, so the same value can mean different moments on different machines.
Q7. How do I keep a full history of changes, not just the last one?
JPA auditing stores only the creator and the latest change. For every version of a row, add Hibernate Envers, which writes each change to audit tables, or publish change events to a separate log. Many applications use both: auditing columns for quick answers, and Envers for the full trail.
Q8. How do I test JPA auditing?
Use @DataJpaTest, import your auditing configuration, and replace the Clock with a fixed or movable one so timestamps are predictable. Put a known user in the security context, save and update an entity, then flush, clear and reload it before checking the four fields.