Spring Boot 4.0 · Spring Framework 7 · Spring Data JPA 4.0 · Jakarta EE 11 · JPA 3.2 · Hibernate ORM 7. Every code sample below was compiled and executed on JDK 21 with Spring Boot 4.0.6 and Spring Data JPA 4.0.6 — the console output you see is the real output, not hand-written.
Most “what’s new in Spring Data JPA 4” posts stop at the version table: bump Jakarta, bump Hibernate, done. That misses the point. The headline change in 4.0 is invisible at the API level — your derived queries no longer run through the Criteria API; they are compiled to JPQL strings — and it sits next to a handful of smaller behavior shifts that compile fine, pass a smoke test, and then surprise you in production. This guide walks the whole path: the boring build changes first, then repositories and null-handling, then query derivation, sorting, the Specification API, and the infrastructure-level gotchas almost nobody writes about. Each section builds on the one before it, so read top to bottom the first time.
Grab the runnable project
Everything here lives in a single Spring Boot project that boots an in-memory H2 database and prints the labelled A–G demonstrations. Download it at the end (or jump straight to it) and run ./mvnw spring-boot:run.
At a glance: what changes, and how risky it is
The whole article in about 30 seconds. Each row links to the section that explains it; “risk” is how likely that area is to need hands-on work or bite you silently during a migration.
| Area | What changes | Migration risk |
|---|---|---|
| Derived queries | Criteria API → JPQL-based execution | High |
| Nullability | JSpecify + runtime parameter validation | High |
| Single-result queries | getSingleResultOrNull() path |
Medium |
| Sorting | Null precedence now maps through Criteria | Medium |
| Specifications | New predicate / update / delete types | Medium |
| Native queries | Parser-selection property removed | Medium |
| Providers | Generated JPQL may expose provider differences | High |
| AOT | Generated repository implementations in AOT mode | Medium |
| Async | ListenableFuture → CompletableFuture |
High |
Risk ratings are practical, not official: “High” means most non-trivial codebases will have to touch it or should regression-test it explicitly; “Medium” means it affects a subset of setups. Every row is verified against the sources listed at the end.
1. The baseline: what “4.0” actually drags in
Spring Data JPA 4.0 is not a standalone upgrade. It is the JPA module of the Spring Data 2025.1 release train, which ships inside Spring Boot 4.0 (GA 20 November 2025). You cannot adopt it without also moving the platform underneath it. Here is the floor:
| Layer | Spring Data JPA 3.x | Spring Data JPA 4.0 |
|---|---|---|
| Spring Framework | 6.x | 7.0 |
| Java baseline | 17 | 17 (any supported LTS for production) |
| Jakarta EE | EE 10 | EE 11 (Servlet 6.1) |
| JPA spec | JPA 3.1 | JPA 3.2 |
| Hibernate ORM | 6.x | 7.1+ (7.2 in current 4.0.x) |
| Nullability | Spring @Nullable |
JSpecify |
| Async wrapper | ListenableFuture |
CompletableFuture only |
On Java specifically: the baseline is Java 17. The Spring Data team points at the latest LTS (JDK 25) as the forward-looking target, but any supported LTS is fine for production — this article’s sample project is built and verified on JDK 21. Read “17” as the floor, not “use only 17.”
Breaking
There is no “just upgrade Spring Data” path. If your project is still on Spring Boot 3 / Framework 6, you are migrating the whole stack at once. Treat this as a Boot 3→4 migration that happens to include Spring Data, not the other way round.
With the floor established, start where the compiler forces you to start: the build.
2. Beginner: the build change you hit before any code
The first thing that breaks is not a repository — it is the annotation processor that generates your JPA static metamodel (Author_, Book_). Hibernate renamed the artifact.
| Spring Data JPA 3.x | Spring Data JPA 4.0 |
|---|---|
org.hibernate.orm:hibernate-jpamodelgen |
org.hibernate.orm:hibernate-processor |
hibernate-proxool, hibernate-vibur |
no longer published |
In Maven, the processor belongs on the compiler plugin’s processor path (Boot’s dependency management supplies the version):
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
After that, the generated metamodel is emitted by the renamed processor — you can see it in the generated source header:
// target/generated-sources/annotations/.../Author_.java
@StaticMetamodel(Author.class)
@Generated("org.hibernate.processor.HibernateProcessor") // <-- was hibernate.jpamodelgen before
public abstract class Author_ {
public static final String NAME = "name";
public static volatile SingularAttribute<Author, String> name;
// ...
}
Silent build break
If you keep the old hibernate-jpamodelgen coordinate, the dependency simply won’t resolve under Boot 4’s BOM, and any type-safe Criteria code that references Author_.name stops compiling. Search your build for jpamodelgen before anything else.
Note also that Boot 4 splits the old spring-boot-starter-* jars into finer spring-boot-* modules, but spring-boot-starter-data-jpa is still the starter you depend on. With the build green, look at the repositories themselves.
3. Repository interfaces: what stayed, what quietly moved
Good news for the 90% case: JpaRepository, CrudRepository, ListCrudRepository, PagingAndSortingRepository and your derived-query methods are source-compatible. A plain repository like this needs zero changes:
public interface AuthorRepository
extends JpaRepository<Author, Long>, JpaSpecificationExecutor<Author> {
Optional<Author> findByName(String name);
List<Author> findByPriceGreaterThanEqual(BigDecimal price);
}
What moved is internal API that leaks into custom infrastructure, module code, and the occasional clever helper:
| Removed / moved in 4.0 | Replacement |
|---|---|
@PersistenceConstructor |
@PersistenceCreator |
org.springframework.data.mapping.PropertyPath |
org.springframework.data.core.PropertyPath |
org.springframework.data.util.TypeInformation |
org.springframework.data.core.TypeInformation |
QPageRequest(...) constructors |
QPageRequest.of(...) factory methods |
SpEL evaluator classes (DefaultSpELExpressionEvaluator, …) |
Value Expression API (ValueExpressionEvaluator) |
org.springframework.data.repository.util.ClassUtils |
org.springframework.data.util.ClassUtils / ReflectionUtils |
Who this hits
If you only write repository interfaces and entities, you’ll never notice these. If you maintain a custom RepositoryFactory, a Querydsl integration, or anything that imports from org.springframework.data.mapping/util, budget an afternoon for import surgery. This is also why third-party Spring Data add-ons often need their own 4.0-compatible release.
The most consequential repository-level change is not on this list, though — it’s how null is treated. That’s next, because it changes runtime behavior even when nothing fails to compile.
4. Null-handling: the JSpecify shift that changes runtime behavior
Through Spring Data 3.x you expressed nullability with Spring’s own annotations (org.springframework.lang.@Nullable, @NonNullApi in package-info.java). As of Spring Framework 7 and Spring Data 4, those are deprecated in favor of JSpecify. You mark a package @NullMarked, and non-null becomes the default for every parameter and return value in it.
// repo/package-info.java
@NullMarked
package com.ankurm.sdjpa4demo.repo;
import org.jspecify.annotations.NullMarked;
Inside that package, three single-result methods now mean three different things:
public interface AuthorRepository extends JpaRepository<Author, Long> {
Optional<Author> findByName(String name); // (1) empty when missing
Author getByName(String name); // (2) non-null: throws when missing,
// rejects a null argument
@Nullable Author findByCountry(@Nullable String country); // (3) may return null,
// tolerates a null argument
}
Running all three against an empty result (verified console output):
================ A. Null-handling of single-result query methods ================
findByName(Optional) missing -> Optional.empty
getByName(non-null) missing -> throws EmptyResultDataAccessException
findByCountry(@Nullable) missing -> null
So far that matches 3.x intuition. The shift is on the argument side. In a @NullMarked package, passing null to a non-null parameter is now rejected at runtime, before the query even runs:
================ B. JSpecify @NullMarked parameter enforcement ================
getByName(null) -> throws IllegalArgumentException:
Parameter name in AuthorRepository.getByName(java.lang.String) must not be null
findByCountry(null) -> allowed (@Nullable param) -> Author{id=3, name='Anonymous', country=null}
Silent behavior shift
Code that used to pass null into a finder — often by accident, from an unvalidated request parameter — and got back null or an empty result will now throw IllegalArgumentException the moment you annotate the package @NullMarked. That’s a good change, but it surfaces bugs that were previously swallowed. Roll @NullMarked out package by package and run your integration tests after each one, rather than annotating everything in a single commit.
This validation happens before the query executes, which makes nullability one of the first runtime behavior changes you’re likely to observe during a migration. The bigger change lives one layer deeper, in how the query itself is now built.
5. The headline change: derived queries now compile to JPQL
Since the inception of Spring Data JPA, a derived method name (findByNameStartingWithOrderByNameAsc) and the built-in SimpleJpaRepository were translated into JPA Criteria API query objects — a point the project lead confirms directly in the tracking issue (spring-data-jpa#3588: “We’ve been using CriteriaQuery for derived queries and SimpleJpaRepository implementations since the inception of Spring Data JPA”). In 4.0 that changes: derived queries are built as JPQL strings instead.
The method signature is identical; only the engine underneath changed. Here’s a derived finder and the SQL it now produces:
List<Author> findByNameStartingWithOrderByNameAsc(String prefix);
================ D. Derived query -> JPQL path ================
Hibernate: select a1_0.id,a1_0.country,a1_0.name
from author a1_0
where a1_0.name like ? escape '\' order by a1_0.name
findByNameStartingWith('J') -> [Joshua Bloch]
Performance context — read the numbers correctly
The motivation is throughput. The framework’s own JMH microbenchmark in issue #3588 measured a single derived finder at roughly 110,633 ops/s via CriteriaBuilder versus 344,518 ops/s as a JPQL string — about 3×, and the release notes summarize it as “3.5× on in-memory databases, ~25% throughput on a typical app” thanks to Hibernate caching the parsed HQL. But these are framework benchmark figures on a specific method, JVM, and in-memory setup — not a universal application-level guarantee. Your real-world gain depends on database, network latency, query complexity, Hibernate version, JVM, and workload. Treat “faster query construction” as the reliable takeaway and measure your own hot paths before quoting a multiplier.
Two things ride along with this rewrite that other guides tend to skip.
5a. getSingleResultOrNull() replaces the NoResultException dance
JPA 3.2 added Query.getSingleResultOrNull(), and Spring Data JPA 4.0 uses it for single-result query methods. Previously the framework called getSingleResult(), caught the NoResultException Hibernate threw, and translated it. The external contract is the same (empty → Optional.empty()/null/EmptyResultDataAccessException), but there is no longer an exception thrown-and-swallowed on the “not found” path. If you had exception breakpoints, logging, or metrics keyed on NoResultException, they’ll go quiet.
5b. “Not found” and “too many” are different exceptions — verify both
A single-result finder that matches more than one row still fails, and it fails differently from “not found”. This is the edge case most blogs never test:
Book findByPrice(BigDecimal price); // two books share price 45.00
================ F. Non-unique single result edge case ================
findByPrice(45.00) matches 2 rows -> throws IncorrectResultSizeDataAccessException
Don’t conflate the two
EmptyResultDataAccessException (nothing found) and IncorrectResultSizeDataAccessException (more than one found) are both subclasses of DataAccessException, and a naïve catch (DataAccessException e) { return null; } hides a genuine data-integrity bug as if it were a harmless miss. If you use non-Optional single-result finders, handle these two cases separately.
5c. The rewrite can change generated JPQL — a real regression to know about
Reimplementing a 15-year-old code path is risky, and the release notes say so outright: “Using a different API that has proven over 15 years bears quite some risk in breaking applications that otherwise ran fine.” A concrete example is already on the tracker. In spring-data-jpa#4167, on EclipseLink 5, derived existsBy… methods now emit JPQL with an implicit select alias:
SELECT o.id id FROM OfUser o WHERE UPPER(o.email) = UPPER(?1) AND o.id != ?2 ...
-- ^^ implicit alias "id" without AS; EclipseLink's Hermes parser rejects it
Silent behavior shift
The exact JPQL string Spring Data now generates is new, and a provider other than Hibernate may parse it differently. On Hibernate (the default) the examples in this article all work; on EclipseLink 5, #4167 reports existsBy… derived queries failing with a grammar error and some positional-parameter binding mismatches, with the workaround being to spell the query out with an explicit @Query. If you run a non-Hibernate provider, treat derived queries as something to regression-test explicitly rather than assume unchanged.
5d. The native-query parser switch was removed
Since 3.0, Spring Data JPA used JSqlParser to introspect native queries, toggled by spring.data.jpa.query.native.parser. In 4.0 the whole DeclaredQuery/QueryEnhancer pipeline was rebuilt and that property is gone. Enhancer selection now happens through a QueryEnhancerSelector, wired on the annotation:
@Configuration
@EnableJpaRepositories(queryEnhancerSelector = MyQueryEnhancerSelector.class)
class ApplicationConfig { }
Breaking
If spring.data.jpa.query.native.parser appears in any application.properties/yaml, it now does nothing. Boot won’t necessarily fail on the unknown property, so this is a silent no-op — grep your config.
Because derived queries are JPQL now, the way sorting — especially sorting around null — is generated also changed. That’s the next building block.
6. Sorting and NULLS precedence, now consistent
Nulls precedence is a good case study in which layer actually changed, because four of them are involved and it’s easy to attribute the behavior to the wrong one. Here is the precise chain:
- Jakarta Persistence 3.2 (the spec) — blesses
NULLS FIRST/NULLS LASTinORDER BYand, crucially, adds the ability to express it through the Criteria API. In JPA 3.1 the Criteria API could not express nulls precedence at all. - Spring Data’s
SortAPI —Sort.Order.nullsFirst()/nullsLast()existed before, but for query paths built on Criteria it had nothing to translate to. On 3.2’s Criteria API it now does, so nulls precedence works consistently across derived queries and Specifications, not only JPQL/native queries. - Hibernate ORM (the provider) — renders the ordering into SQL, and may omit a redundant clause (see below).
- The database dialect — decides the default null ordering when no clause is emitted.
In short: the capability is a JPA 3.2 standard feature exposed through the Criteria API; Spring Data simply now has something to map Sort onto. The observable result:
authors.findByNameStartingWith("", Sort.by(Sort.Order.asc("country").nullsFirst()));
authors.findByNameStartingWith("", Sort.by(Sort.Order.asc("country").nullsLast()));
================ C. NULLS FIRST vs NULLS LAST in Sort ================
country ASC NULLS FIRST -> [null, CH, DE, UK, US]
country ASC NULLS LAST -> [CH, DE, UK, US, null]
Now the detail nobody mentions. Look at the actual SQL the two calls generate:
-- NULLS FIRST:
... order by a1_0.country
-- NULLS LAST:
... order by a1_0.country asc nulls last
Silent detail worth knowing
This is the Hibernate + dialect layer, not Spring Data. Hibernate omits the explicit nulls first clause when it already matches the database’s default null ordering (H2 sorts nulls first on ascending order), and emits nulls last only when it must override that default. The result set is correct either way, but the same Sort can produce different SQL text on databases whose default differs (PostgreSQL and Oracle default to nulls last on ascending). Assert on the returned order, never on the SQL string.
Sorting leads naturally into the richer JPQL you can now write by hand, because the same JPA 3.2 upgrade unlocked a batch of new functions — and it’s worth being precise about which are portable and which are Hibernate-only.
7. Intermediate: new query functions — standard vs Hibernate-only
Spring Data JPA 4.0 passes whatever the underlying stack accepts straight through @Query. Two very different things got wider here, and conflating them is how you accidentally write a non-portable query.
Standard Jakarta Persistence 3.2 additions (portable)
These are part of the JPA 3.2 specification itself, supported in both JPQL and the Criteria API, so they are portable across compliant providers (Hibernate, EclipseLink):
- The
||string-concatenation operator - String functions
replace(),left(),right() cast()- Set operators
union,intersect,except(with a newCriteriaSelectinterface on the Criteria side)
@Query("select replace(a.name, ' ', '_') || ':' || coalesce(a.country, 'N/A') "
+ "from Author a where a.id = :id")
String badgeFor(@Param("id") Long id);
================ E. New JPQL functions ( || and replace() ) ================
-- generated SQL:
select ((replace(a1_0.name,' ','_')||':')||coalesce(a1_0.country,'N/A'))
from author a1_0 where a1_0.id=?
badgeFor(gof) -> Erich_Gamma:CH
badgeFor(anon) -> Anonymous:N/A
Hibernate-specific extensions (not portable)
These are Hibernate ORM features, not part of Jakarta Persistence 3.2. They work because Hibernate is your provider — switch to EclipseLink and they disappear:
- JSON functions such as
json_object() - XML functions such as
xmlquery() - Hibernate’s JSON/XML set-returning functions
Portability tip
If a query only uses items from the first list, it stays provider-portable and you may be able to convert an old nativeQuery = true back to JPQL — dialect-portable and cacheable again. If it reaches for the second list, you’ve made a deliberate bet on Hibernate; that’s fine, just document it so nobody assumes the repository is provider-agnostic.
Hand-written @Query is one half of advanced querying; the programmatic half — the Specification API — was reshaped in 4.0. That’s the expert section.
8. Expert: the refined Specification API
The classic Specification<T> you pass to JpaSpecificationExecutor still works for reads. What changed is that Spring Data stopped forcing every specification through CriteriaQuery, because delete and update use a different Criteria hierarchy (CriteriaDelete/CriteriaUpdate). 4.0 introduces purpose-built types:
| Type | Use it for |
|---|---|
Specification<T> |
SELECT predicates (unchanged for callers) |
DeleteSpecification<T> |
bulk deletes over CriteriaDelete |
UpdateSpecification<T> |
bulk updates over CriteriaUpdate |
PredicateSpecification<T> |
reusable predicate fragments, query-type-agnostic |
Two more Specification-related refinements are easy to miss:
Fluent findBy(…) can return a Slice without a count query. Previously a paginated fluent query always issued a count. Now you opt into the count only when you need it (and can pass an optimized count specification), which removes a needless SELECT count(*) from endpoints that only render “next page” navigation.
Lightweight JpaSort.unsafe(…) parsing for Specifications. A new ORDER BY parser translates simple path expressions, function calls, and CASE clauses into Criteria expressions when you combine JpaSort.unsafe(...) with a Specification. Anything more complex should still go straight to CriteriaQuery.
Migration note
If you built bulk delete/update on top of Specification via workarounds, move them onto DeleteSpecification/UpdateSpecification — the workarounds relied on the very CriteriaQuery coupling that 4.0 removed. Plain read specifications need no change.
For comparison, a derived deleteBy… still works and still returns the affected count:
@Modifying @Transactional
long deleteByCountry(@Nullable String country);
================ G. Derived delete returns count ================
deleteByCountry('DE') removed -> 1 row(s)
The last building block is the one that causes the most confusing production incidents, because it’s about wiring rather than queries.
9. Expert / easily missed: EntityManager wiring and AOT repositories
9a. The shared EntityManager is no longer auto-registered
Spring’s LocalContainerEntityManagerFactoryBean already creates a shared EntityManager, so Spring Data 4.0 stopped registering its own SharedEntityManagerCreator when the EntityManagerFactory comes from AbstractEntityManagerFactoryBean. It also deprecated — and no longer auto-registers — EntityManagerBeanDefinitionRegistrarPostProcessor.
Breaking for some setups
If you inject a bare @Autowired EntityManager (or @PersistenceContext onto a field expecting the Spring-registered shared bean) in code that doesn’t go through the standard Boot auto-configuration, that bean may no longer exist. Re-register EntityManagerBeanDefinitionRegistrarPostProcessor yourself, or provide a BiPredicate<String, BeanDefinition> to control which factories get a SharedEntityManagerCreator. Standard spring-boot-starter-data-jpa apps are unaffected; hand-rolled multi-datasource configs are the ones to check.
9b. AOT repositories are on by default
Spring Data JPA, JDBC, MongoDB and Cassandra now ship Ahead-of-Time repository support: query-method implementations are generated at build time instead of via reflection at runtime. It’s enabled by default in AOT mode. The upside is faster startup, lower memory, native-image friendliness, and — usefully — you can now step into a generated query method in a debugger. The knobs:
# turn AOT repositories off entirely
spring.aot.repositories.enabled=false
# or per module
spring.aot.jpa.repositories.enabled=false
Note
AOT-generated repository fragments are only used when you actually run in AOT mode (native image, or spring-boot:process-aot). Ordinary ./mvnw spring-boot:run during development still uses the runtime path, so behavior is identical — but if you build a native image, this is the mechanism generating your query methods.
10. The small removals that still bite
ListenableFutureis gone.@Asyncquery methods must returnCompletableFuture. Spring Framework 7 removedListenableFuture, and Spring Data followed.- Jackson 3. Spring Data REST 5.0 moves to Jackson 3 (core package
tools.jackson.*; the annotations stay undercom.fasterxml.jacksonbut must be a 3.x version). If you expose repositories over REST and customize serialization, this is a separate migration. @PersistenceConstructor→@PersistenceCreatoron entities with multiple constructors.
11. Migration checklist
- Move the platform first: Spring Boot 4.0.x (pulls Framework 7, Jakarta EE 11, JPA 3.2, Hibernate 7). Confirm JDK 17+.
- Replace
hibernate-jpamodelgenwithhibernate-processoron the compiler processor path. - Grep config for
spring.data.jpa.query.native.parser— remove it; adoptQueryEnhancerSelectorif you relied on it. - Fix imports for
PropertyPath/TypeInformation(→org.springframework.data.core),@PersistenceCreator, andQPageRequest.of(...). - Introduce JSpecify
@NullMarkedpackage by package; run integration tests after each package to catch newly-rejectednullarguments. - Audit single-result finders: separate
EmptyResultDataAccessExceptionfromIncorrectResultSizeDataAccessExceptionhandling. - Move any
Specification-based bulk delete/update ontoDeleteSpecification/UpdateSpecification. - Check hand-rolled
EntityManagerinjection in non-standard datasource configs. - Switch
@Asyncrepository methods fromListenableFuturetoCompletableFuture. - Re-run the full suite with SQL logging on and diff generated queries against a 3.x baseline — the Criteria→JPQL rewrite is where any real regression will show up (doubly so on non-Hibernate providers, per §5c).
The one test that matters most
Because derived queries changed engines, the highest-value verification is a regression run with spring.jpa.show-sql=true, comparing the SQL your derived methods emit before and after. Assert on results, not SQL text (see §6), but eyeball the SQL for anything unexpected — that’s where a 15-year-old Criteria assumption might surface differently.
12. Download and run the verified sample
The complete project used for every output above — four entities, two repositories, a @NullMarked package, seven passing behavior tests, and the A–G demo runner — is a single download. It boots H2 in memory, so there’s nothing to install beyond a JDK.
unzip sdjpa4-demo.zip && cd sdjpa4-demo
./mvnw spring-boot:run # prints the labelled A–G demonstrations
./mvnw test # runs the 7 behavior tests (all green)
Built and verified on Eclipse Temurin JDK 21.0.11, Spring Boot 4.0.6, Spring Data JPA 4.0.6, Hibernate ORM 7.2, H2 in-memory. All console output shown above is copied from the actual run.
Sources
- Spring Data 2025.1 (4.0) Release Notes — spring-data-commons wiki (baseline, JPQL derived queries, nulls precedence, Specification API)
- spring-data-jpa#3588 — Replace derived CriteriaQuery with String-based queries (benchmark numbers, “since the inception” history)
- spring-data-jpa#4167 — EclipseLink 5: derived existsBy… fails due to implicit select alias (real post-4.0 regression)
- Introduction to Jakarta Persistence 3.2 — Baeldung and A summary of Jakarta Persistence 3.2 — In Relation To (standard functions, set operators, nulls precedence in Criteria)
- Null Handling of Repository Methods — Spring Data Commons reference · JSpecify documentation
- Spring Boot 4.0.0 available now