Deep-dive companion #2 of 3 to the Spring Data JPA 3 to 4 Migration Guide. Spring Boot 4.0.6 · Spring Data JPA 4.0.6 · Hibernate ORM 7.2 · JDK 21. Every code sample and console output below is copied from a real, compiled, executed run.
Your repository method signature does not change between Spring Data JPA 3 and 4. Call findByNameStartingWithOrderByNameAsc("J") in both versions and you get the same list back. What changed is invisible from the method declaration: the engine that turns that method name into SQL was replaced. In 3.x it went through the JPA Criteria API. In 4.0 it compiles to a JPQL string instead. This article is entirely about that rewrite — why it happened, what it’s worth in practice, and the smaller behavior shifts riding along with it.
| # | Article | Focus |
|---|---|---|
| 0 | Full Guide (Overview) | Beginner-to-advanced walkthrough of the whole repository layer; this series is its deep-dive companion |
| 1 | Migration Baseline | Platform, repositories, nullability, compatibility |
| 2 | Query Engine Rewrite (this article) | Criteria → JPQL, provider behavior, sorting, query functions |
| 3 | Advanced Migration | Specifications, AOT, EntityManager wiring |
Companion code
All examples in this series are compiled and executed against Spring Boot 4.0.6, Spring Data JPA 4.0.6, Hibernate ORM 7.2, and JDK 21, in the companion repository sdjpa4-demo. This article’s code: repo/AuthorRepository.java, repo/BookRepository.java, DemoRunner.java (demonstrations C, D, E, F), and MigrationBehaviorTests.java. Check out the tag for this article: git checkout article-2-query-engine.
Two Different Execution Paths
Same method signature in, same SQL out — the difference is entirely in the middle of the pipeline. In Spring Data JPA 3, a derived query method goes through the Criteria API:
Spring Data JPA 3
Derived query method
↓
Criteria API
↓
Hibernate
↓
SQL
In Spring Data JPA 4, the same method goes through JPQL instead:
Spring Data JPA 4
Derived query method
↓
JPQL string
↓
Hibernate HQL parser/cache
↓
SQL
The rest of this article is about what that swap costs and buys you, and the smaller changes that come along with it.
Why Spring Data Changed the Engine
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.
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]
The Benchmark, Read 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.
Performance context — read the numbers correctly
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.
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.
Not Found vs Too Many: Two Different Exceptions
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.
A Real Regression: EclipseLink and Implicit Aliases
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.
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. Details: release notes §Revision of DeclaredQuery and Introduction of QueryEnhancerSelector.
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 (release notes: §Nulls Precedence). 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.
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.
The One Regression Test That Matters Most
Because derived queries changed engines, the highest-value verification for this whole rewrite is a regression run with spring.jpa.show-sql=true, comparing the SQL your derived methods emit before and after moving to 4.0.
Assert on results, not SQL text
Eyeball the SQL for anything unexpected — that’s where a 15-year-old Criteria assumption might surface differently — but assert your tests on the returned data, never on the SQL string. As the NULLS precedence example above shows, the same correct result can legitimately come from different SQL text depending on the database dialect.
Get the Code
The derived-query, sorting, and JPQL-function demonstrations above come from the same companion project used across this series, sdjpa4-demo. It boots H2 in memory, so there’s nothing to install beyond a JDK. The repo has since grown beyond this article too — check out corner-scenarios (or just use main) for additional real, tested corner cases (the refined Specification API, JpaSort.unsafe, and an @Embeddable value object).
git clone https://ankurm.com/git.app/asmhatre/sdjpa4-demo.git
cd sdjpa4-demo
git checkout article-2-query-engine
./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 (JPQL derived queries, nulls precedence)
- 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)
Continue the Series
Full guide: Spring Data JPA 3 to 4 Migration Guide (overview)
Previous: Article 1 — Migration Baseline
Next: Article 3 — Advanced Migration: Specifications, AOT, and EntityManager Wiring
No Comments yet!