5.0 KiB
23 — Pagination
← Previous: 22 — Sorting | Next: 24 — Interceptors → | Back to README →
Backs the rewrite of ankurm.com post 4890 (pagination).
setFirstResult/setMaxResults translate to the dialect's real syntax
query.setFirstResult(2);
query.setMaxResults(2);
generates, on H2:
... order by a1_0.sequence offset ? rows fetch first ? rows only
Different dialects render this differently (LIMIT ... OFFSET ... on MySQL/PostgreSQL,
OFFSET ... FETCH ... on SQL Server/H2, ROWNUM tricks on older Oracle) — the JPA-level API is
the same everywhere; only the generated SQL shape changes.
PaginationTest.java —
output
ScrollableResults with ScrollMode.FORWARD_ONLY
try (ScrollableResults<Article> results = session.createQuery(hql, Article.class)
.setReadOnly(true)
.scroll(ScrollMode.FORWARD_ONLY)) {
while (results.next()) {
Article a = results.get();
// process one row at a time
}
}
No List<Article> holding every row is built by application code — rows are pulled from the JDBC
ResultSet one at a time as next()/get() are called. Whether this actually avoids loading the
whole result set into memory server-side too depends on the JDBC driver's own fetch-size
behavior, not on this API alone.
Test —
output
JOIN FETCH + pagination: it depends on what you order by
The often-repeated claim is that join fetch over a to-many association combined with
setFirstResult/setMaxResults always falls back to loading everything into memory and
paginating in application code, logging a warning. Tested directly against
hibernate-core-7.4.5.Final.jar, this is only half true:
- Ordering by a column on the root entity (
order by a.sequence): Hibernate 7.4.5's query translator paginates a derived subquery of root ids first (its ownOFFSET/FETCH), then joins the fetched collection onto that already-paginated set of ids. No in-memory fallback, no warning. - Ordering by a column on the fetched collection itself (
order by c.body, wherecis the joined collection alias): the "paginate the root ids first" trick can't work, because the sort key isn't a root-entity column. This is the query shape that reproduces the real in-memory fallback.
-- root-ordered: paginates a derived subquery of ids, then joins
select a1_0.id, c1_0.article_id, ... from (
select distinct a1_0.id, a1_0.sequence, a1_0.title from article a1_0
where ... order by a1_0.sequence offset ? rows fetch first ? rows only
) a1_0 join comment c1_0 on a1_0.id = c1_0.article_id order by a1_0.sequence
The warning's real message and code, read directly out of QueryLogging.i18n.properties in the
jar:
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Not HHH000104, a code sometimes quoted for this that belongs to a different, older message
entirely — checked with javap against QueryLogging_$logger.class, not assumed from a search
result.
Tests —
root-ordered output,
collection-ordered output
Keyset (seek) pagination
select a from Article a where a.id > :lastId order by a.id asc
with setMaxResults(pageSize) and no setFirstResult at all. Each page's WHERE clause carries
the previous page's last id, so the database never has to count-and-skip rows the way OFFSET
does — the cost of fetching page 500 is the same as fetching page 1.
Test —
output
The total-count-query pattern
A "Page 2 of 7" UI needs two separate queries — a COUNT and a LIMIT/OFFSET SELECT — not
one query doing both; SQL has no way to return a page of rows and the total matching count in a
single result set without a window function trick most codebases don't bother with for this.
Test —
output
Going deeper
- Deep
OFFSETpagination degrades because the database still has to generate and discard every skipped row before reaching the page — keyset pagination sidesteps this entirely, at the cost of not supporting arbitrary "jump to page N" navigation. Slice/Pageabstractions (Spring Data) wrap the count-query pattern automatically; knowing the two-query shape underneath explains why aPageablewithunpaged()sort still issues aCOUNT.- Hibernate ORM 7.4 User Guide — pagination