Pagination and Specifications in Spring Data JPA
Pageable, Page vs Slice, keyset pagination at scale, and building dynamic queries with JPA Specifications and the Criteria API.
Pagination and Specifications
Every list endpoint you ship needs an answer to two questions: how do you avoid loading the entire table into memory, and how do you support arbitrary combinations of filters without writing a repository method for every permutation? Spring Data JPA answers the first with Pageable/Page<T> and the second with Specification<T> over the JPA Criteria API. This guide covers both — including the offset-pagination performance cliff you'll eventually hit at scale, and the type-safety trade-offs of building queries dynamically.
1. Why Pagination Isn't Optional
// Never ship this in a list endpoint
@GetMapping("/orders")
public List<Order> getAllOrders() {
return orderRepository.findAll(); // loads the entire table into JVM heap
}An unbounded findAll() behind a public API endpoint is a resource-exhaustion vulnerability, not just a performance concern. As the table grows, this endpoint's memory footprint and response time grow with it — with no cap, a single client (or a scraper) can trigger an OOM by hitting it repeatedly, or the response payload itself becomes unusably large. Every list endpoint needs a page size ceiling, enforced server-side, from day one.
2. Pageable, PageRequest, and Sort
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
}@GetMapping("/orders")
public Page<OrderSummaryDto> getOrders(
@RequestParam Long customerId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(
page,
Math.min(size, 100), // enforce a hard ceiling server-side
Sort.by(Sort.Direction.DESC, "createdAt")
);
return orderRepository.findByCustomerId(customerId, pageable)
.map(OrderSummaryDto::from);
}Spring MVC can also resolve Pageable directly from query parameters (?page=0&size=20&sort=createdAt,desc) via PageableHandlerMethodArgumentResolver, which is auto-configured by Spring Boot's web starter.
@GetMapping("/orders")
public Page<OrderSummaryDto> getOrders(
@RequestParam Long customerId,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return orderRepository.findByCustomerId(customerId, pageable).map(OrderSummaryDto::from);
}Always clamp the requested page size server-side. A client-controlled size parameter with no upper bound (?size=1000000) turns your pagination safeguard into exactly the unbounded query you were trying to prevent. @PageableDefault(size = 20) sets a default, not a ceiling — enforce the max explicitly, e.g. with a custom Pageable resolver or a manual Math.min() clamp.
Multi-field sorting
Sort sort = Sort.by(Sort.Order.desc("status"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(0, 20, sort);ORDER BY status DESC, created_at ASCNever build Sort directly from a raw, unvalidated request parameter string (e.g., Sort.by(request.getParameter("sort")) against a field name allowlist you don't control). Spring Data will happily translate it into an ORDER BY clause against whatever entity attribute name matches — including nested associations. An unvalidated sort parameter is a minor information-disclosure and DoS vector (sorting by an unindexed or deeply nested path). Always validate the sort field against an explicit allowlist of permitted attribute names.
3. Page<T> vs Slice<T>
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable); // needs total count
Slice<Order> findByCustomerId(Long customerId, Pageable pageable); // no total count needed
}| Aspect | Page<T> | Slice<T> |
|---|---|---|
| Total element count | Yes (getTotalElements(), getTotalPages()) | No |
| Extra query | Runs a separate COUNT(*) query | None — fetches pageSize + 1 rows to detect hasNext() |
| Cost on large tables | COUNT(*) over a filtered predicate can be expensive at scale | Cheaper — no count query at all |
| Use when | You render page numbers / "1 of 400 pages" UI | You render "load more" / infinite scroll UI |
If your UI is infinite-scroll or "load more" style and doesn't need a total page count, use Slice<T> — it skips the COUNT(*) query entirely, which on a large, heavily-filtered table can be as expensive as the data query itself.
4. Offset Pagination's Scaling Cliff
Pageable's default mechanism is offset pagination — LIMIT/OFFSET under the hood:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;The database must still scan and discard the first 100,000 matching rows before it can return the next 20 — OFFSET is not an index seek. Deep pages get progressively slower as OFFSET grows, even though each page returns the same number of rows.
Fix: keyset (seek) pagination
Instead of "skip N rows," keyset pagination asks "give me the next rows after this specific cursor value" — which the database can answer with an index seek, independent of how deep you are.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT o FROM Order o
WHERE o.createdAt < :cursor
ORDER BY o.createdAt DESC
""")
List<Order> findNextPage(@Param("cursor") Instant cursor, Pageable pageable);
}@GetMapping("/orders")
public CursorPage<OrderSummaryDto> getOrders(
@RequestParam(required = false) Instant cursor,
@RequestParam(defaultValue = "20") int size) {
Instant effectiveCursor = cursor != null ? cursor : Instant.now();
List<Order> orders = orderRepository.findNextPage(
effectiveCursor, PageRequest.of(0, Math.min(size, 100)));
Instant nextCursor = orders.isEmpty() ? null : orders.get(orders.size() - 1).getCreatedAt();
return new CursorPage<>(orders.stream().map(OrderSummaryDto::from).toList(), nextCursor);
}-- backed by an index on created_at — a seek, not a scan, regardless of "depth"
SELECT * FROM orders WHERE created_at < ? ORDER BY created_at DESC LIMIT 20;Offset vs keyset comparison
| Aspect | Offset pagination (Pageable) | Keyset / seek pagination |
|---|---|---|
| Query cost at deep pages | Degrades linearly with offset | Constant (index seek) |
| Jump to arbitrary page number | Supported | Not directly supported |
| Handles concurrent inserts/deletes | Rows can shift between pages (duplicates/skips) | Stable — cursor is a real value, not a position |
| Implementation complexity | Built into Spring Data (Pageable) | Requires a custom @Query and a unique, indexed, monotonic sort column |
| Best for | Admin UIs with page-number navigation, small-to-medium tables | High-traffic feeds, infinite scroll, large tables |
Keyset pagination needs a sort key that is unique and totally ordered — a timestamp alone can have duplicates. In practice, pair it with a tiebreaker: WHERE (created_at, id) < (:cursorTime, :cursorId) ensures a stable, gap-free cursor even when multiple rows share the same timestamp.
5. JPA Specifications: Dynamic Queries
Derived query methods and static @Query JPQL both require you to know the filter combination at compile time. When an endpoint needs to support an arbitrary combination of optional filters — "search orders by any combination of status, customer, date range, and minimum amount" — Specification<T> composes predicates at runtime.
public interface OrderRepository extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> {
}JpaSpecificationExecutor<T> adds findAll(Specification<T> spec), findAll(Specification<T> spec, Pageable pageable), and count(Specification<T> spec).
public class OrderSpecifications {
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) ->
status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<Order> hasCustomerId(Long customerId) {
return (root, query, cb) ->
customerId == null ? null : cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> createdBetween(Instant from, Instant to) {
return (root, query, cb) -> {
if (from == null && to == null) return null;
if (from == null) return cb.lessThanOrEqualTo(root.get("createdAt"), to);
if (to == null) return cb.greaterThanOrEqualTo(root.get("createdAt"), from);
return cb.between(root.get("createdAt"), from, to);
};
}
public static Specification<Order> minAmount(BigDecimal min) {
return (root, query, cb) ->
min == null ? null : cb.greaterThanOrEqualTo(root.get("totalAmount"), min);
}
}Returning null from a Specification lambda is a deliberate, documented convention — Specification.where() and .and() treat a null predicate as "no constraint," which is exactly what you want for an unset optional filter. This is what lets each specification stay self-contained and composable without null-checking logic scattered at the call site.
Composing specifications
@Service
public class OrderQueryService {
private final OrderRepository orderRepository;
public Page<Order> search(OrderSearchCriteria criteria, Pageable pageable) {
Specification<Order> spec = Specification
.where(OrderSpecifications.hasStatus(criteria.status()))
.and(OrderSpecifications.hasCustomerId(criteria.customerId()))
.and(OrderSpecifications.createdBetween(criteria.from(), criteria.to()))
.and(OrderSpecifications.minAmount(criteria.minAmount()));
return orderRepository.findAll(spec, pageable);
}
}Every combination of set/unset filters produces a correctly-shaped WHERE clause with no branching logic in the service — the null-safe composition above handles it automatically.
6. The Criteria API Underneath
Specification<T> is a thin functional wrapper around JPA's Criteria API — the same type-safe, programmatic query-building API you can use directly for cases too dynamic even for specifications (arbitrary joins, subqueries, aggregate grouping).
public List<Order> findHighValueOrdersByJoin(BigDecimal minAmount, String customerCountry) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> order = query.from(Order.class);
Join<Order, Customer> customer = order.join("customer", JoinType.INNER);
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.greaterThanOrEqualTo(order.get("totalAmount"), minAmount));
predicates.add(cb.equal(customer.get("country"), customerCountry));
query.select(order)
.where(cb.and(predicates.toArray(new Predicate[0])))
.orderBy(cb.desc(order.get("createdAt")));
return entityManager.createQuery(query)
.setMaxResults(50)
.getResultList();
}Criteria API building blocks
| Component | Role |
|---|---|
CriteriaBuilder | Factory for predicates, expressions, orderings — the "query DSL" entry point |
CriteriaQuery<T> | The query being built — analogous to a JPQL query string, but as an object graph |
Root<T> | The FROM clause entity — root.get("field") navigates attributes |
Predicate | A WHERE-clause condition (cb.equal, cb.and, cb.like, etc.) |
Join<X, Y> | An explicit join, analogous to JPQL's JOIN |
root.get("totalAmount") uses a string literal attribute name — it compiles even if totalAmount is renamed or removed, and fails only at runtime. This is the Criteria API's biggest ergonomic weakness compared to JPQL string queries (which at least fail loudly and identically) or plain method-derived queries (which fail at compile time).
7. Type-Safe Criteria Queries with the Static Metamodel
Hibernate's annotation processor (hibernate-jpamodelgen) generates a static metamodel class for every entity at build time, giving you compile-time-checked attribute references instead of string literals.
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<scope>provided</scope>
</dependency>For an Order entity, this generates Order_ with static, typed attribute references:
// generated: Order_.java
public abstract class Order_ {
public static volatile SingularAttribute<Order, BigDecimal> totalAmount;
public static volatile SingularAttribute<Order, OrderStatus> status;
public static volatile SingularAttribute<Order, Customer> customer;
}public static Specification<Order> minAmountTypeSafe(BigDecimal min) {
return (root, query, cb) ->
min == null ? null : cb.greaterThanOrEqualTo(root.get(Order_.totalAmount), min);
}Now a rename of totalAmount on the entity breaks the build at compile time instead of surfacing as a runtime IllegalArgumentException: Unable to resolve attribute.
The static metamodel is generated during the compile phase (an annotation processor), so it needs to be regenerated whenever entity fields change — most IDEs and build tools handle this automatically as part of the normal build, but it's worth knowing it exists as a build step if metamodel classes ever appear stale or missing.
8. Specifications vs Query Methods vs Criteria API
| Approach | Best for | Type safety | Composability |
|---|---|---|---|
| Derived query methods | 1-3 fixed, known filter combinations | Compile-time (method signature) | None — one method per combination |
@Query (JPQL) | Fixed, complex queries known in advance | Runtime (string parsing) | None |
Specification<T> | Dynamic filter combinations, "search" endpoints | Runtime by default, compile-time with metamodel | High — .and()/.or() compose freely |
| Raw Criteria API | Highly dynamic queries needing joins, subqueries, aggregates beyond Specification's scope | Runtime by default, compile-time with metamodel | Manual, full control |
Don't reach for Specification by default — it adds indirection that's only worth it once you have genuinely dynamic, optional filter combinations. For a fixed set of known filters, a derived query method or a single @Query is more readable and easier for the next engineer to trace.
9. Production Observations
- Enforce a hard max page size server-side — never trust a client-supplied
sizeparameter as a ceiling. - Use
Slice<T>for infinite-scroll UIs to skip theCOUNT(*)query; reservePage<T>for UIs that genuinely need total counts. - Switch to keyset pagination once offset depth becomes a measured problem — don't prematurely complicate every list endpoint, but know the migration path (add a covering index on the sort key, add a cursor tiebreaker) before you need it under pressure.
- Validate sort parameters against an allowlist — never pass a raw client string straight into
Sort.by(...). - Add the
hibernate-jpamodelgenmetamodel dependency early in any project usingSpecification/Criteria API heavily — retrofitting it into a codebase full of string-literalroot.get("...")calls later is a large, low-value refactor to do all at once.
Key takeaways
- Never ship an unbounded
findAll()behind a public list endpoint — always paginate, and clamp the page size server-side. Page<T>runs an extraCOUNT(*)query for total counts;Slice<T>skips it and is cheaper for "load more" UIs.- Offset pagination (
LIMIT/OFFSET) degrades as the offset grows because the database must scan and discard preceding rows; keyset pagination avoids this with an index seek on a cursor value. - Keyset pagination needs a unique, totally-ordered sort key — pair a timestamp with an ID tiebreaker to avoid gaps or duplicates from ties.
Specification<T>composes null-safe predicates with.and()/.or()— returningnullfrom a specification lambda means "no constraint," not an error.- The Criteria API underlies
Specificationand uses string attribute names by default (root.get("field")), which is a runtime, not compile-time, failure mode. - The Hibernate static metamodel (
hibernate-jpamodelgen) restores compile-time safety for Criteria/Specification field references. - Don't default to
Specificationfor fixed, known filter sets — it's the right tool specifically for dynamic, optional filter combinations.
Interview Questions
- Why is an unbounded
findAll()dangerous behind a public REST endpoint? - What is the difference between
Page<T>andSlice<T>, and when would you choose each? - Why does offset pagination (
LIMIT/OFFSET) get slower on deeper pages, even though each page returns the same number of rows? - How does keyset (seek) pagination avoid the offset-pagination scaling cliff? What are its limitations?
- Why does keyset pagination need a unique, totally-ordered sort key, and how do you handle ties?
- What does
JpaSpecificationExecutor<T>add to a repository interface? - Why do
Specificationlambdas conventionally returnnullfor an unset filter, and how does.and()handle that? - What are the core building blocks of the JPA Criteria API (
CriteriaBuilder,CriteriaQuery,Root,Predicate)? - What is the main weakness of using string literals like
root.get("totalAmount")in Criteria queries? - How does the Hibernate static metamodel (
hibernate-jpamodelgen) address that weakness? - When would you choose a derived query method over a
Specification, and vice versa? - Why is validating a client-supplied
sortparameter against an allowlist important? - How would you design a "search orders" endpoint that supports five optional, independently combinable filters?