Hibernate Pagination: Transitioning from Offset to Keyset for Large Datasets
0 reputation · 10 Apr 2021, 11:51 UTC
0 reputation · 10 Apr 2021, 11:51 UTC
Hibernate ORM provides pagination through setFirstResult() and setMaxResults(), which typically translate to LIMIT and OFFSET clauses depending on the SQL Dialect. While effective for shallow pages, this offset-based approach requires the database to scan and discard preceding rows, leading to linear performance degradation as the offset increases.
A critical boundary exists when combining pagination with HQL join fetches. In certain configurations, Hibernate may trigger in-memory pagination, loading the entire result set into the JVM before slicing, which risks OutOfMemoryError on large datasets.
To maintain constant-time performance, keyset pagination (the seek method) is documented as a viable alternative to avoid the overhead of deep offsets.
35275 reputation · 10 Apr 2021, 21:19 UTC
To keep the same EntityManager / Query creation path but avoid deep OFFSET scans, replace the offset logic with a key‑based predicate on the ORDER BY column. Bind the last seen key value and only set setMaxResults(pageSize)—do not call setFirstResult(). The HQL/JPQL string changes, but the rest of the API stays identical.
// Single‑column unique key
String hql = "from Order o where o.id > :lastId order by o.id asc";
TypedQuery q = em.createQuery(hql, Order.class);
q.setParameter("lastId", lastId); // last id from previous page
q.setMaxResults(pageSize); // no setFirstResult
List page = q.getResultList();
For a composite key you can use a lexicographic comparison that most dialects support:
String hql = "from Order o where (o.a, o.b) > (:lastA, :lastB) order by o.a asc, o.b asc";
// or, if row‑value syntax is unavailable:
// where o.a > :lastA or (o.a = :lastA and o.b > :lastB)
Hibernate pushes LIMIT/OFFSET to SQL only when it can apply the slice after the database has returned rows. A trigger for in‑memory pagination is:
join fetch that pulls a collection association (one‑to‑many or many‑to‑many) together with setFirstResult() or setMaxResults().select distinct in combination with a collection join fetch, which forces Hibernate to deduplicate rows in the JVM.In these cases the SQL generated contains the LIMIT/OFFSET, but Hibernate reads the full join result set into memory and performs the slicing after hydration. This can lead to OutOfMemoryError on large datasets.
join fetch if you only need a page of root entities. Fetch the collection lazily or in a separate query.join fetch on to‑one associations; these are usually compatible with SQL‑level pagination.OFFSET in a keyset query, and the query plan should show an index seek on the sort key.Does your current query include a join fetch of a collection association? If so, keyset pagination alone will not prevent in‑memory hydration; you’ll need to adjust the fetch strategy as described above.
• The guidance assumes a relational database with an indexed sort key and a Hibernate version that supports SQL‑level LIMIT/OFFSET pushing (Hibernate 5.4+). Exact behavior can differ in older releases or in dialects lacking OFFSET support.
• Composite keyset predicates may require dialect‑specific syntax; the examples use standard JPQL where possible.
• The in‑memory pagination trigger is documented in Hibernate’s migration notes, but the exact log message and whether pagination is pushed can vary by version and configuration. Verify against your environment.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.