Dung (Donny) Nguyen

Senior Software Engineer

Pagination, Filtering & Sorting in Spring Boot

Returning every row from a table in a single response works fine with ten records. With ten million, it turns into an OutOfMemoryError waiting to happen, a slow response, and a client stuck rendering a page nobody can scroll through. Pagination, filtering, and sorting are the three tools that keep list endpoints fast, predictable, and usable — no matter how large the underlying dataset grows.


Why This Matters

Spring Data JPA has first-class support for all three through Pageable, Sort, and Specification, so you rarely need to write this logic by hand.


Pagination

Pageable and Page<T>

Spring Data’s PagingAndSortingRepository (included in JpaRepository) accepts a Pageable and returns a Page<T>, which wraps the content plus metadata about the total result set.

public interface UserRepository extends JpaRepository<User, Long> {
}
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @GetMapping
    public Page<User> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return userRepository.findAll(PageRequest.of(page, size));
    }
}

A request to /api/users?page=0&size=20 returns:

{
  "content": [ /* up to 20 users */ ],
  "totalElements": 4821,
  "totalPages": 242,
  "number": 0,
  "size": 20,
  "first": true,
  "last": false
}

Letting Spring Bind Pageable Automatically

Instead of accepting raw page/size parameters and building PageRequest yourself, let Spring resolve a Pageable argument directly from the query string:

@GetMapping
public Page<User> getUsers(Pageable pageable) {
    return userRepository.findAll(pageable);
}

This automatically understands ?page=0&size=20&sort=lastName,asc with no extra code. Cap the maximum page size so a client can’t request size=1000000 and defeat the purpose of pagination:

@Bean
public PageableHandlerMethodArgumentResolverCustomizer pageableCustomizer() {
    return resolver -> {
        resolver.setMaxPageSize(100);
        resolver.setFallbackPageable(PageRequest.of(0, 20));
    };
}

Offset vs. Keyset (Cursor) Pagination

PageRequest.of(page, size) uses offset pagination under the hood — the database still has to scan and skip page * size rows before it can return the next batch. That’s fine for page 2, but page 50,000 on a large table gets progressively slower.

For infinite-scroll feeds or very large tables, keyset (cursor) pagination — fetching rows “after the last ID/timestamp you saw” — avoids the OFFSET scan entirely:

public interface UserRepository extends JpaRepository<User, Long> {

    List<User> findTop20ByIdGreaterThanOrderByIdAsc(Long lastSeenId);
}
@GetMapping
public List<User> getUsers(@RequestParam(defaultValue = "0") Long cursor) {
    return userRepository.findTop20ByIdGreaterThanOrderByIdAsc(cursor);
}

Use offset pagination for admin screens with page numbers; use keyset pagination for feeds and large tables where jumping to “page 500” isn’t a real use case.


Sorting

Sorting via Pageable

When Pageable is bound automatically, sorting comes for free through the sort query parameter:

GET /api/users?page=0&size=20&sort=lastName,asc&sort=firstName,asc

Multiple sort parameters apply in order, so this sorts by lastName first, then firstName as a tiebreaker.

Sorting Standalone (Without Pagination)

If an endpoint doesn’t paginate but still needs sorting, accept a Sort directly:

@GetMapping("/all")
public List<User> getAllUsers(Sort sort) {
    return userRepository.findAll(sort);
}

Guarding Against Invalid Sort Fields

Pageable/Sort binding trusts whatever property name the client sends. Sorting by a field that doesn’t exist on the entity throws a PropertyReferenceException at query time — and sorting by an unintended field (like a relation that triggers an expensive join) is a real risk. Validate the sort properties against an allow-list before executing the query:

private static final Set<String> SORTABLE_FIELDS = Set.of("id", "firstName", "lastName", "createdAt");

private void validateSort(Sort sort) {
    sort.forEach(order -> {
        if (!SORTABLE_FIELDS.contains(order.getProperty())) {
            throw new IllegalArgumentException("Cannot sort by: " + order.getProperty());
        }
    });
}

Filtering

Simple Filters with Derived Query Methods

For a handful of fixed filter combinations, Spring Data’s method-name-derived queries are the simplest option:

public interface UserRepository extends JpaRepository<User, Long> {

    Page<User> findByStatusAndCountry(String status, String country, Pageable pageable);
}

This works well until the number of optional filter combinations grows — a findByStatusAndCountryAndDepartmentAndActive... signature becomes unmanageable fast.

Dynamic Filters with Specification

When filters are optional and combined at runtime (a typical “search” endpoint where any subset of fields may be present), Specification<T> builds the WHERE clause dynamically instead of needing one method per combination:

public class UserSpecifications {

    public static Specification<User> hasStatus(String status) {
        return (root, query, cb) ->
                status == null ? null : cb.equal(root.get("status"), status);
    }

    public static Specification<User> hasCountry(String country) {
        return (root, query, cb) ->
                country == null ? null : cb.equal(root.get("country"), country);
    }

    public static Specification<User> nameContains(String keyword) {
        return (root, query, cb) ->
                keyword == null ? null : cb.like(cb.lower(root.get("lastName")), "%" + keyword.toLowerCase() + "%");
    }
}
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
}
@GetMapping
public Page<User> searchUsers(
        @RequestParam(required = false) String status,
        @RequestParam(required = false) String country,
        @RequestParam(required = false) String keyword,
        Pageable pageable) {

    Specification<User> spec = Specification
            .where(UserSpecifications.hasStatus(status))
            .and(UserSpecifications.hasCountry(country))
            .and(UserSpecifications.nameContains(keyword));

    return userRepository.findAll(spec, pageable);
}

Returning null from a Specification lambda tells Spring to skip that predicate entirely, so each filter is only applied when the client actually supplies it — and Specification composes with Pageable/Sort out of the box.

Filtering with Querydsl (Alternative)

Specification is verbose for large filter sets. Querydsl’s generated QUser type-safe predicates are a common alternative that reads closer to a fluent query builder, at the cost of an extra annotation-processing build step. Pick one approach per project — mixing both adds maintenance overhead for no benefit.


Putting It Together

A realistic “search users” endpoint combining all three:

@GetMapping("/search")
public Page<User> searchUsers(
        @RequestParam(required = false) String status,
        @RequestParam(required = false) String keyword,
        Pageable pageable) {

    validateSort(pageable.getSort());

    Specification<User> spec = Specification
            .where(UserSpecifications.hasStatus(status))
            .and(UserSpecifications.nameContains(keyword));

    return userRepository.findAll(spec, pageable);
}
GET /api/users/search?status=ACTIVE&keyword=nguyen&page=0&size=20&sort=lastName,asc

One endpoint, three concerns, each handled by a purpose-built Spring Data abstraction instead of hand-rolled SQL string concatenation.


Best Practices