Update (2026-05-10): investigation found a simpler smart-OR rewrite that achieves the same Postgres plan without requiring tuple support, on every dialect. See the comment below — the reproduction repo and the proposal can be implemented with that single algebraic rewrite inside KeysetScrollDelegate.createPredicate. The original text is preserved below for context.
Summary
KeysetScrollDelegate.createPredicate always emits keyset condition as a disjunctive
normal form (OR-form), even on databases that natively support row-value tuple
comparison (a, b) < (?, ?). On Postgres this turns what should be a sub-millisecond
Index Cond into a filter scan over millions of rows. I propose detecting the
relevant preconditions and auto-using a tuple shape when they hold, keeping OR-form
as the fallback (Postgres/MySQL/H2 + uniform sort direction + non-null keys is a
common case).
Current behavior
KeysetScrollDelegate.createPredicate (spring-data-jpa
KeysetScrollDelegate.java#L76)
algorithmically builds:
WHERE (a < :ts) OR (a = :ts AND b < :id)
This is implemented through QueryStrategy.compare(Order, expression, value) which
operates on a single column (see KeysetScrollSpecification$JpqlStrategy.compare,
line 155).
A tuple comparison cannot be expressed through this API.
Performance impact (Postgres 17, 2M rows, page ~50 000)
Composite index (status, created_at, id), query: WHERE status = 'PAID' [+ keyset] ORDER BY created_at DESC, id DESC LIMIT 5.
| Predicate shape |
Plan |
Time |
Buffers |
| OR-form (current) |
Index Cond: status + Filter (Rows Removed by Filter: 999996) |
692 ms |
1 005 833 |
(created_at, id) < (?, ?) |
Index Cond: status AND ROW(...) < ROW(...) |
0.27 ms |
8 |
Full EXPLAIN (ANALYZE, BUFFERS) for both shapes is printed by
KeysetSqlShapeTest.explainAnalyze_orForm_vs_rowValue in the reproduction repo
(mvn test runs it).
Two orders of magnitude on a single keyset hop, three on a representative deep page.
The Filter happens because Postgres cannot use the composite index for
(a < ?) OR (a = ? AND b < ?) — it scans matching status rows linearly.
Row-value comparison is a single index range, so it stops at LIMIT. (Postgres can in
theory plan the OR-form via BitmapOr, but does not in this case on tested versions
13–17 because per-branch selectivity estimates remain too high.)
Hibernate already handles HQL tuple comparison: (o.createdAt, o.id) < (:ts, :id)
parses as SqmTuple (HqlParser.g4 TupleExpression and BinaryExpressionPredicate)
and translates to SqlTuple. On dialects where supportsRowValueConstructorGtLtSyntax()
returns true (Postgres/MySQL/H2/CockroachDB/MariaDB), the translator emits the tuple
verbatim; on dialects where it returns false, Hibernate emulates the comparison —
which is the same OR-form rewrite Spring Data is doing now, just one layer up.
Verified by capturing org.hibernate.SQL=DEBUG output of an @Query HQL string.
Why the current shape exists
Three real constraints make OR-form a safe universal default:
- Dialect support is mixed. Per Hibernate 7.2.12 the default of
Dialect.supportsRowValueConstructorGtLtSyntax()
is true, overridden to false (directly or via supportsRowValueConstructorSyntax)
in: Oracle, SQL Server, DB2, Sybase, HANA, Spanner, HSQLDB. Asking the dialect
covers all current and future cases without an explicit list in Spring Data.
- Mixed sort directions. Row-value
(a, b) < (?, ?) cannot express
ORDER BY a ASC, b DESC — directions must align. Sort accepts arbitrary mixes,
so OR-form is the only universal lowering for that shape.
- NULL semantics.
(NULL, 5) < (1, 10) evaluates to UNKNOWN, which loses rows
silently. The current JpqlStrategy.compare explicitly handles isNullsLast /
requiresNonNullTail; equivalent NULL-aware row-value would compose
(a, b) < (?, ?) OR a IS NULL and partly defeat the optimization.
OR-form is the right universal default. The proposal below is to keep it as a
fallback and use the tuple shape only when all three constraints are statically
known to be safe.
Why not fix it in Hibernate?
A reasonable alternative would be: have Hibernate's SqlAstTranslator recognize
the OR-shape Spring Data emits and rewrite it back to a tuple on dialects that
support GtLt. This does not work — by the time SQL AST sees the predicate it is
just a hand-written disjunction of conditions; the information that "these N
predicates form a keyset tuple" only exists at the Spring Data layer and is lost
in the lowering. The fix has to be made where the intent is still known.
Proposed solution
Auto-detect the three preconditions, no API change. When all of these hold:
- Dialect reports
supportsRowValueConstructorGtLtSyntax() == true
- All
Sort.Order entries point in the same direction
- All keyset properties are mapped to non-nullable columns (or caller opts in
declaring NULLs forbidden)
…short-circuit inside KeysetScrollDelegate.createPredicate
to emit a tuple node before the diagonal loop runs. Falls back to the existing
OR-form construction otherwise. The QueryStrategy interface stays the same;
only JpqlStrategy (and its Criteria sibling) gain an internal helper that
produces the tuple expression via JpqlQueryBuilder.
The tuple Predicate composes with arbitrary Specification-built predicates
identically to the OR-form — it's just a different shape produced by the same
strategy — so Specification interop and the existing AOT path are preserved.
If touching JpqlStrategy is undesirable, the alternative is to add the same
logic at the JpqlQueryBuilder level and let it pick the shape based on dialect
metadata available there. Either way no public API changes.
Workaround used today
Custom repository fragment with EntityManager + manual Window.from(items, positionFunction, hasNext) and HQL string with tuple comparison. About 35 lines per
entity. Reference implementation in the reproduction repo.
This is the same shape KeysetScrollDelegate would emit, just bypassed at the
strategy layer.
Related
Reproduction
https://github.com/revenant20/spring-data-keyset-rowvalue-repro — minimal Spring Boot 4.0.6
project. Runs against Testcontainers Postgres. Two test methods capture the SQL emitted
by Spring Data's derived query (Window<T> via findFirst20ByStatus) and by a custom
repository fragment using HQL row-value, asserting the difference. EXPLAIN ANALYZE
output is printed to stdout for both shapes.
mvn test is the full reproduction.
Summary
KeysetScrollDelegate.createPredicatealways emits keyset condition as a disjunctivenormal form (OR-form), even on databases that natively support row-value tuple
comparison
(a, b) < (?, ?). On Postgres this turns what should be a sub-millisecondIndex Condinto a filter scan over millions of rows. I propose detecting therelevant preconditions and auto-using a tuple shape when they hold, keeping OR-form
as the fallback (Postgres/MySQL/H2 + uniform sort direction + non-null keys is a
common case).
Current behavior
KeysetScrollDelegate.createPredicate(spring-data-jpaKeysetScrollDelegate.java#L76)algorithmically builds:
This is implemented through
QueryStrategy.compare(Order, expression, value)whichoperates on a single column (see
KeysetScrollSpecification$JpqlStrategy.compare,line 155).
A tuple comparison cannot be expressed through this API.
Performance impact (Postgres 17, 2M rows, page ~50 000)
Composite index
(status, created_at, id), query:WHERE status = 'PAID' [+ keyset] ORDER BY created_at DESC, id DESC LIMIT 5.Index Cond: status+Filter(Rows Removed by Filter: 999996)(created_at, id) < (?, ?)Index Cond: status AND ROW(...) < ROW(...)Full
EXPLAIN (ANALYZE, BUFFERS)for both shapes is printed byKeysetSqlShapeTest.explainAnalyze_orForm_vs_rowValuein the reproduction repo(
mvn testruns it).Two orders of magnitude on a single keyset hop, three on a representative deep page.
The Filter happens because Postgres cannot use the composite index for
(a < ?) OR (a = ? AND b < ?)— it scans matchingstatusrows linearly.Row-value comparison is a single index range, so it stops at LIMIT. (Postgres can in
theory plan the OR-form via BitmapOr, but does not in this case on tested versions
13–17 because per-branch selectivity estimates remain too high.)
Hibernate already handles HQL tuple comparison:
(o.createdAt, o.id) < (:ts, :id)parses as
SqmTuple(HqlParser.g4TupleExpressionandBinaryExpressionPredicate)and translates to
SqlTuple. On dialects wheresupportsRowValueConstructorGtLtSyntax()returns true (Postgres/MySQL/H2/CockroachDB/MariaDB), the translator emits the tuple
verbatim; on dialects where it returns false, Hibernate emulates the comparison —
which is the same OR-form rewrite Spring Data is doing now, just one layer up.
Verified by capturing
org.hibernate.SQL=DEBUGoutput of an@QueryHQL string.Why the current shape exists
Three real constraints make OR-form a safe universal default:
Dialect.supportsRowValueConstructorGtLtSyntax()is
true, overridden tofalse(directly or viasupportsRowValueConstructorSyntax)in: Oracle, SQL Server, DB2, Sybase, HANA, Spanner, HSQLDB. Asking the dialect
covers all current and future cases without an explicit list in Spring Data.
(a, b) < (?, ?)cannot expressORDER BY a ASC, b DESC— directions must align.Sortaccepts arbitrary mixes,so OR-form is the only universal lowering for that shape.
(NULL, 5) < (1, 10)evaluates to UNKNOWN, which loses rowssilently. The current
JpqlStrategy.compareexplicitly handlesisNullsLast/requiresNonNullTail; equivalent NULL-aware row-value would compose(a, b) < (?, ?) OR a IS NULLand partly defeat the optimization.OR-form is the right universal default. The proposal below is to keep it as a
fallback and use the tuple shape only when all three constraints are statically
known to be safe.
Why not fix it in Hibernate?
A reasonable alternative would be: have Hibernate's
SqlAstTranslatorrecognizethe OR-shape Spring Data emits and rewrite it back to a tuple on dialects that
support GtLt. This does not work — by the time SQL AST sees the predicate it is
just a hand-written disjunction of conditions; the information that "these N
predicates form a keyset tuple" only exists at the Spring Data layer and is lost
in the lowering. The fix has to be made where the intent is still known.
Proposed solution
Auto-detect the three preconditions, no API change. When all of these hold:
supportsRowValueConstructorGtLtSyntax() == trueSort.Orderentries point in the same directiondeclaring NULLs forbidden)
…short-circuit inside
KeysetScrollDelegate.createPredicateto emit a tuple node before the diagonal loop runs. Falls back to the existing
OR-form construction otherwise. The
QueryStrategyinterface stays the same;only
JpqlStrategy(and its Criteria sibling) gain an internal helper thatproduces the tuple expression via
JpqlQueryBuilder.The tuple
Predicatecomposes with arbitrarySpecification-built predicatesidentically to the OR-form — it's just a different shape produced by the same
strategy — so
Specificationinterop and the existing AOT path are preserved.If touching
JpqlStrategyis undesirable, the alternative is to add the samelogic at the
JpqlQueryBuilderlevel and let it pick the shape based on dialectmetadata available there. Either way no public API changes.
Workaround used today
Custom repository fragment with
EntityManager+ manualWindow.from(items, positionFunction, hasNext)and HQL string with tuple comparison. About 35 lines perentity. Reference implementation in the reproduction repo.
This is the same shape
KeysetScrollDelegatewould emit, just bypassed at thestrategy layer.
Related
Investigate keyset-scrolling using HQL/JPQL query methods #2886 proposes new public surface (
Window<T>return type for@Querymethods,requiring HQL parsing changes for keyset semantics). This ticket proposes no API
change — only an internal predicate optimization in the existing derived-query /
Specificationspath. They are independent and could land in either order.CriteriaQuerywith String-based JPQL queries #3588 — CriteriaQuery → JPQL string migration (closed). Showed a 3× throughputimprovement from string-based JPQL over CriteriaBuilder; relevant context that
predicate-shape choices are worth investing in.
OR-form for nullable columns; this issue proposes a shape change for the non-null
case. Both can land independently. If KeysetScrollSpecification null properties support #3605 lands first, the precondition check
here gets simpler.
Reproduction
https://github.com/revenant20/spring-data-keyset-rowvalue-repro — minimal Spring Boot 4.0.6
project. Runs against Testcontainers Postgres. Two test methods capture the SQL emitted
by Spring Data's derived query (
Window<T>viafindFirst20ByStatus) and by a customrepository fragment using HQL row-value, asserting the difference. EXPLAIN ANALYZE
output is printed to stdout for both shapes.
mvn testis the full reproduction.