Files
hibernate-demo/docs/23-pagination.md
T

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 own OFFSET/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, where c is 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 OFFSET pagination 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/Page abstractions (Spring Data) wrap the count-query pattern automatically; knowing the two-query shape underneath explains why a Pageable with unpaged() sort still issues a COUNT.
  • Hibernate ORM 7.4 User Guide — pagination