Hibernate 6.x: Converting setFirstResult Pagination to Cursor-Based Retrieval
0 reputation · 24 Dec 2025, 23:40 UTC
0 reputation · 24 Dec 2025, 23:40 UTC
We need to replace the traditional offset‑based paging that uses setFirstResult and setMaxResults in Hibernate 6.x with a cursor‑based approach that references a monotonic primary key. The target is a table that contains millions of rows where large offsets cause full table scans and inconsistent results if rows are inserted or deleted between requests.
Offset paging triggers a LIMIT/OFFSET clause that the database must evaluate sequentially, leading to linear time and memory consumption. Cursor paging can avoid this but requires a different query construction pattern and may interact with fetch‑mode settings that affect entity materialization.
setFirstResult?FetchMode.SELECT to FetchMode.SUBSELECT influence memory usage when cursor paging large entity graphs?setFirstResult and setMaxResults to a manual WHERE clause that filters by a monotonic column (typically the primary key).Offset-based paging requires the database to scan and discard all rows prior to the offset, resulting in O(n) complexity. Cursor-based paging uses indexed comparisons to jump directly to the next set of results, resulting in O(log n) or O(1) performance depending on the index structure.
In Hibernate 6.x, instead of calling setFirstResult(offset), define a predicate based on the ID of the last item from the previous page.
// Example JPQL approach
SELECT e FROM Entity e WHERE e.id > :lastId ORDER BY e.id ASC
// Implementation
List<Entity> results = entityManager.createQuery(
"SELECT e FROM Entity e WHERE e.id > :lastId ORDER BY e.id ASC", Entity.class)
.setParameter("lastId", lastSeenId)
.setMaxResults(pageSize)
.getResultList();
If using the Criteria API, use the CriteriaBuilder to add the comparison:
CriteriaQuery<Entity> query = cb.createQuery(Entity.class);
Root<Entity> root = query.from(Entity.class);
query.select(root).where(cb.gt(root.get("id"), lastSeenId))
.orderBy(cb.asc(root.get("id")));
List<Entity> results = entityManager.createQuery(query).setMaxResults(pageSize).getResultList();
Switching between fetch modes significantly affects how Hibernate materializes entity graphs during cursor paging:
Cursor paging preserves consistent ordering across concurrent inserts or deletes because it references a specific point value rather than a relative position. If a row is inserted with an ID lower than the current cursor, it does not shift the result set. If the current row is deleted, the cursor remains valid for the next request because it looks for the "next greater" value. To guarantee this, the ORDER BY clause must include a unique column. If sorting by a non-unique column (like "status"), you must include the primary key as a tie-breaker (e.g., ORDER BY status ASC, id ASC) to ensure deterministic cursor behavior.
Note: Ensure that the column used for the cursor is indexed to avoid the performance degradation you seek to escape.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 25 Dec 2025, 08:16 UTC
When the primary sort key isn’t unique (e.g., createdAt), build a deterministic tie‑breaker using the primary key:
SELECT e FROM Entity e WHERE (e.createdAt, e.id) > (:lastCreatedAt, :lastId) ORDER BY e.createdAt ASC, e.id ASC LIMIT :pageSize
Hibernate 6.x translates the tuple comparison into separate predicates that the underlying dialect can optimise. Verify that the database supports row‑value predicates or rewrite as a compound OR condition if necessary.
setMaxResults after the join; Hibernate will still return only pageSize distinct root entities, but the generated SQL may include DISTINCT or a GROUP BY clause.CriteriaQuery.select(root).distinct(true) when the page size is critical.Ensure an index covers createdAt, id (or the chosen order columns). The generated query will perform a range scan, not a full sort, keeping latency low even for deep pages.