Pagination and Sorting in Spring Boot

Returning all records from a large table in a single response is a recipe for slow APIs and crashed servers. Pagination is not optional — this article shows how to implement it properly with Spring Data.

The Problem with Returning Everything

// Never do this for large datasets
@GetMapping("/api/orders")
public List<Order> getOrders() {
    return orderRepository.findAll(); // 1 million orders → OutOfMemoryError
}

Even for “small” tables, always paginate. Requirements change, data grows.

Spring Data Pagination: The Key Types

TypePurpose
PageableInput: which page, how many, sorted how
Page<T>Output: the data + metadata (total count, total pages)
Slice<T>Output: the data + has-next (no count query — more efficient)
PageRequestConcrete Pageable implementation

Pageable in Controllers

Spring MVC can resolve Pageable from query parameters automatically:

@GetMapping("/api/orders")
public Page<OrderResponse> listOrders(Pageable pageable) {
    return orderRepository.findAll(pageable)
        .map(OrderResponse::from);
}

Client sends:

GET /api/orders?page=0&size=20&sort=createdAt,desc
GET /api/orders?page=1&size=10&sort=status,asc&sort=createdAt,desc

Parameters:

  • page — zero-based page number (default: 0)
  • size — items per page (default: 20)
  • sortfield,direction (direction: asc or desc, default: asc)

Enable it (already enabled by default in Spring Boot web apps):

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        // Already registered — but you can customize defaults:
        PageableHandlerMethodArgumentResolver resolver =
            new PageableHandlerMethodArgumentResolver();
        resolver.setDefaultPageable(PageRequest.of(0, 20));
        resolver.setMaxPageSize(100);  // cap page size to prevent abuse
        resolvers.add(resolver);
    }
}

Using @PageableDefault

Set per-endpoint defaults without configuration:

@GetMapping("/api/orders")
public Page<OrderResponse> listOrders(
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable) {

    return orderRepository.findAll(pageable).map(OrderResponse::from);
}

The Page Response

Page<T> from Spring Data contains everything the client needs to navigate:

{
  "content": [
    { "id": "...", "status": "PENDING", ... },
    { "id": "...", "status": "CONFIRMED", ... }
  ],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 20,
    "sort": { "sorted": true, "orders": [{ "property": "createdAt", "direction": "DESC" }] }
  },
  "totalElements": 247,
  "totalPages": 13,
  "last": false,
  "first": true,
  "numberOfElements": 20,
  "empty": false
}

The default serialization is verbose. Build a custom response wrapper:

public record PagedResponse<T>(
    List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean last
) {
    public static <T, R> PagedResponse<R> from(Page<T> page, Function<T, R> mapper) {
        return new PagedResponse<>(
            page.getContent().stream().map(mapper).toList(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.isLast()
        );
    }
}

Use it:

@GetMapping("/api/orders")
public PagedResponse<OrderResponse> listOrders(
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable) {

    Page<Order> page = orderRepository.findAll(pageable);
    return PagedResponse.from(page, OrderResponse::from);
}

Client gets:

{
  "content": [...],
  "page": 0,
  "size": 20,
  "totalElements": 247,
  "totalPages": 13,
  "last": false
}

Clean, predictable, client-friendly.

Paginated Repository Methods

Spring Data JPA generates pagination queries automatically:

public interface OrderRepository extends JpaRepository<Order, UUID> {

    // Spring Data generates: SELECT ... WHERE customer_id = ? ... LIMIT ? OFFSET ?
    Page<Order> findByCustomerId(UUID customerId, Pageable pageable);

    // Filter by status
    Page<Order> findByStatus(OrderStatus status, Pageable pageable);

    // Multiple filters
    Page<Order> findByCustomerIdAndStatus(UUID customerId, OrderStatus status, Pageable pageable);

    // Custom JPQL with pagination
    @Query("SELECT o FROM Order o WHERE o.totalAmount > :minAmount ORDER BY o.createdAt DESC")
    Page<Order> findLargeOrders(@Param("minAmount") BigDecimal minAmount, Pageable pageable);
}

Slice: When You Don’t Need the Total Count

Page<T> executes a COUNT(*) query to compute totalElements and totalPages. For large tables, this count query can be expensive.

Slice<T> skips the count — it only tells you whether there’s a next page:

public interface OrderRepository extends JpaRepository<Order, UUID> {
    Slice<Order> findByStatus(OrderStatus status, Pageable pageable);
}
@GetMapping("/api/orders/feed")
public SliceResponse<OrderResponse> getFeed(
        @PageableDefault(size = 20) Pageable pageable) {

    Slice<Order> slice = orderRepository.findByStatus(OrderStatus.PENDING, pageable);
    return new SliceResponse<>(
        slice.getContent().stream().map(OrderResponse::from).toList(),
        slice.hasNext(),
        slice.getNumber(),
        slice.getSize()
    );
}

public record SliceResponse<T>(
    List<T> content,
    boolean hasNext,
    int page,
    int size
) {}

Use Slice for “infinite scroll” UIs and feeds where you don’t need the total count.

Sorting

Via Pageable query params

GET /api/orders?sort=createdAt,desc
GET /api/orders?sort=status,asc&sort=createdAt,desc  (multi-column)

Restricting sortable fields

By default, clients can sort on any field — including internal fields. Validate sort properties:

@GetMapping("/api/orders")
public PagedResponse<OrderResponse> listOrders(
        @PageableDefault(size = 20) Pageable pageable,
        HttpServletRequest request) {

    // Whitelist allowed sort fields
    Set<String> allowedSortFields = Set.of("createdAt", "status", "totalAmount");
    pageable.getSort().forEach(order -> {
        if (!allowedSortFields.contains(order.getProperty())) {
            throw new BadRequestException(
                "Cannot sort by '" + order.getProperty() + "'. " +
                "Allowed: " + allowedSortFields
            );
        }
    });

    return PagedResponse.from(
        orderRepository.findAll(pageable),
        OrderResponse::from
    );
}

Or use SortHandlerMethodArgumentResolverCustomizer:

@Bean
public SortHandlerMethodArgumentResolverCustomizer sortCustomizer() {
    return resolver -> resolver.setPropertyDelimiter("_");
}

Sort in @Query

When you write @Query, don’t hardcode ORDER BY — let the Pageable sort parameter control it. Spring Data handles this automatically if you don’t add ORDER BY in the query:

// CORRECT — Pageable's sort is applied
@Query("SELECT o FROM Order o WHERE o.customerId = :id")
Page<Order> findByCustomer(@Param("id") UUID id, Pageable pageable);

// If you need a default in the query itself:
@Query("SELECT o FROM Order o WHERE o.customerId = :id")
Page<Order> findByCustomer(@Param("id") UUID id,
                           @PageableDefault(sort = "createdAt") Pageable pageable);

Cursor-Based Pagination (Keyset)

Offset-based pagination (LIMIT n OFFSET m) has a performance problem: as the offset grows, the DB must skip more rows. For page 500 of 20 results, the DB fetches 10,020 rows and discards the first 10,000.

Keyset pagination uses the last seen value as the cursor:

// Instead of: GET /api/orders?page=10&size=20
// Use:        GET /api/orders?cursor=2026-05-01T10:00:00Z&size=20

@GetMapping("/api/orders")
public CursorPagedResponse<OrderResponse> listOrders(
        @RequestParam(required = false) Instant cursor,
        @RequestParam(defaultValue = "20") @Max(100) int size) {

    List<Order> orders;
    if (cursor == null) {
        orders = orderRepository.findTopN(size + 1, Sort.by("createdAt").descending());
    } else {
        orders = orderRepository.findBeforeCursor(cursor, size + 1);
    }

    boolean hasNext = orders.size() > size;
    List<Order> pageContent = hasNext ? orders.subList(0, size) : orders;
    Instant nextCursor = hasNext ? pageContent.getLast().getCreatedAt() : null;

    return new CursorPagedResponse<>(
        pageContent.stream().map(OrderResponse::from).toList(),
        nextCursor,
        hasNext
    );
}

public record CursorPagedResponse<T>(
    List<T> content,
    Instant nextCursor,
    boolean hasNext
) {}
// Repository
@Query("""
    SELECT o FROM Order o
    WHERE o.createdAt < :cursor
    ORDER BY o.createdAt DESC
    LIMIT :limit
    """)
List<Order> findBeforeCursor(@Param("cursor") Instant cursor, @Param("limit") int limit);

Keyset pagination is O(1) regardless of page depth — use it for large or frequently-updated datasets.

Preventing Pagination Abuse

Without limits, a client could request size=1000000:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public PageableHandlerMethodArgumentResolver pageableResolver() {
        PageableHandlerMethodArgumentResolver resolver =
            new PageableHandlerMethodArgumentResolver();
        resolver.setMaxPageSize(100);       // hard cap
        resolver.setDefaultPageable(PageRequest.of(0, 20));
        return resolver;
    }
}

Or validate in the handler:

@ExceptionHandler(MethodArgumentNotValidException.class)
// ... see Article 12

// Or inline:
if (pageable.getPageSize() > 100) {
    throw new BadRequestException("Maximum page size is 100");
}

Complete Example

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping
    public PagedResponse<OrderResponse> listOrders(
            @RequestParam(required = false) UUID customerId,
            @RequestParam(required = false) OrderStatus status,
            @RequestParam(required = false)
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
            @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
            Pageable pageable) {

        OrderFilter filter = new OrderFilter(customerId, status, from);
        Page<Order> page = orderService.findAll(filter, pageable);
        return PagedResponse.from(page, OrderResponse::from);
    }
}
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository repository;

    public Page<Order> findAll(OrderFilter filter, Pageable pageable) {
        return repository.findAll(filter.toSpec(), pageable);
    }
}
// Service layer builds the Specification
public record OrderFilter(UUID customerId, OrderStatus status, Instant from) {

    public Specification<Order> toSpec() {
        return Specification
            .where(hasCustomer(customerId))
            .and(hasStatus(status))
            .and(createdAfter(from));
    }

    private Specification<Order> hasCustomer(UUID id) {
        return id == null ? null
            : (root, q, cb) -> cb.equal(root.get("customerId"), id);
    }

    private Specification<Order> hasStatus(OrderStatus s) {
        return s == null ? null
            : (root, q, cb) -> cb.equal(root.get("status"), s);
    }

    private Specification<Order> createdAfter(Instant instant) {
        return instant == null ? null
            : (root, q, cb) -> cb.greaterThan(root.get("createdAt"), instant);
    }
}

What You’ve Learned

  • Never return all records — always paginate
  • Pageable is injected from query params (page, size, sort); use @PageableDefault for per-endpoint defaults
  • Page<T> includes total count; Slice<T> skips the count query (better for large tables)
  • Build a custom PagedResponse wrapper to control the JSON shape
  • Cap maxPageSize to prevent abuse
  • Keyset (cursor-based) pagination is O(1) — use it for large, frequently-updated datasets

Next: Article 14 — API Documentation with OpenAPI and Springdoc — auto-generate interactive API docs from your code.