04-persistence-data-access

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.

August 14, 2026
backend-engineerpaginationspecificationscriteriadynamic-queries

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

java
// 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

java
public interface OrderRepository extends JpaRepository<Order, Long> {
    Page<Order> findByCustomerId(Long customerId, Pageable pageable);
}
java
@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.

java
@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

java
Sort sort = Sort.by(Sort.Order.desc("status"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(0, 20, sort);
sql
ORDER BY status DESC, created_at ASC
🚨

Never 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>

java
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
}
AspectPage<T>Slice<T>
Total element countYes (getTotalElements(), getTotalPages())No
Extra queryRuns a separate COUNT(*) queryNone — fetches pageSize + 1 rows to detect hasNext()
Cost on large tablesCOUNT(*) over a filtered predicate can be expensive at scaleCheaper — no count query at all
Use whenYou render page numbers / "1 of 400 pages" UIYou 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 paginationLIMIT/OFFSET under the hood:

sql
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.

java
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);
}
java
@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);
}
sql
-- 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

AspectOffset pagination (Pageable)Keyset / seek pagination
Query cost at deep pagesDegrades linearly with offsetConstant (index seek)
Jump to arbitrary page numberSupportedNot directly supported
Handles concurrent inserts/deletesRows can shift between pages (duplicates/skips)Stable — cursor is a real value, not a position
Implementation complexityBuilt into Spring Data (Pageable)Requires a custom @Query and a unique, indexed, monotonic sort column
Best forAdmin UIs with page-number navigation, small-to-medium tablesHigh-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.

java
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).

java
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

java
@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).

java
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

ComponentRole
CriteriaBuilderFactory 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
PredicateA 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.

xml
<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:

java
// 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;
}
java
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

ApproachBest forType safetyComposability
Derived query methods1-3 fixed, known filter combinationsCompile-time (method signature)None — one method per combination
@Query (JPQL)Fixed, complex queries known in advanceRuntime (string parsing)None
Specification<T>Dynamic filter combinations, "search" endpointsRuntime by default, compile-time with metamodelHigh — .and()/.or() compose freely
Raw Criteria APIHighly dynamic queries needing joins, subqueries, aggregates beyond Specification's scopeRuntime by default, compile-time with metamodelManual, 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 size parameter as a ceiling.
  • Use Slice<T> for infinite-scroll UIs to skip the COUNT(*) query; reserve Page<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-jpamodelgen metamodel dependency early in any project using Specification/Criteria API heavily — retrofitting it into a codebase full of string-literal root.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 extra COUNT(*) 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() — returning null from a specification lambda means "no constraint," not an error.
  • The Criteria API underlies Specification and 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 Specification for 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> and Slice<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 Specification lambdas conventionally return null for 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 sort parameter against an allowlist important?
  • How would you design a "search orders" endpoint that supports five optional, independently combinable filters?