Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 038 / 100

Pagination with Spring Data Pageable: a practical guide

Never return unbounded lists.

Expona Admin6 min read

The Problem with Unbounded Lists

We've all seen it happen: a junior developer adds a new API endpoint, the product team celebrates the feature, and six months later the database is groaning under the weight of queries returning millions of records. Pagination with Spring Data Pageable: a practical guide exists because returning unbounded lists kills both database performance and user experience. When an endpoint returns everything, you're not building an API—you're building a time bomb.

In this guide, we'll walk through the production-ready patterns we use across our trading and e-commerce platforms to keep pagination fast, memory-efficient, and user-friendly.

Accept Pageable in Controller Methods

Spring Data gives us Pageable, a first-class interface that handles the parsing of pagination parameters from HTTP requests. Instead of manually parsing page and size query parameters, we declare it as a method argument and Spring wires it for us.

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

  private final OrderRepository orderRepository;

  public OrderController(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }

  @GetMapping
  public ResponseEntity<Page<OrderDTO>> listOrders(
      @RequestParam(defaultValue = "0") int page,
      @RequestParam(defaultValue = "20") int size,
      @RequestParam(defaultValue = "id,desc") String sort,
      Pageable pageable) {
    
    Page<Order> orders = orderRepository.findAll(pageable);
    Page<OrderDTO> dtos = orders.map(this::toDTO);
    
    return ResponseEntity.ok(dtos);
  }

  private OrderDTO toDTO(Order order) {
    return new OrderDTO(order.getId(), order.getStatus(), order.getTotal());
  }
}

When a client hits /api/orders?page=0&size=20&sort=id,desc, Spring parses that into a Pageable object automatically. The repository method receives it and applies limit and offset to the query. No string parsing, no SQL injection risk, no manual offset math.

The key is that Pageable isn't just a convenience—it's a contract. Your repository method now signals: "I expect pagination." Anyone reading the code knows the results are bounded.

Cap the Max Page Size Globally

Left unchecked, a client can request size=1000000 and bypass your best intentions. We cap the maximum page size at the application level so no endpoint can return more records than we've deemed safe.

Configure this in your Spring properties file:

java
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.one-indexed-parameters=false

Now any request with size=200 is silently clamped to 100. For endpoints that need smaller sizes (say, dropdown lists), override on a per-method basis:

java
@GetMapping("/search")
public ResponseEntity<Page<OrderDTO>> searchOrders(
    @PageableDefault(size = 10, page = 0) Pageable pageable) {
  
  Page<Order> orders = orderRepository.findAll(pageable);
  return ResponseEntity.ok(orders.map(this::toDTO));
}

The @PageableDefault annotation overrides the global default for just this method. If a client doesn't specify size, they get 10. If they ask for 500, they get the global cap of 100.

This approach has saved us countless times. We've caught malicious clients and well-meaning frontend devs alike trying to load entire datasets into memory.

Return Content Plus Total or a Next Cursor

Spring's Page<T> object serializes beautifully to JSON and gives clients everything they need to build pagination UI: the current page, total elements, total pages, and whether there's a next or previous page.

java
@GetMapping
public ResponseEntity<Page<OrderDTO>> listOrders(Pageable pageable) {
  Page<Order> page = orderRepository.findAll(pageable);
  return ResponseEntity.ok(page.map(this::toDTO));
}

A client receives JSON like this:

java
{
  "content": [
    { "id": 1, "status": "shipped", "total": 99.99 },
    { "id": 2, "status": "pending", "total": 149.50 }
  ],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 20,
    "sort": { "unsorted": false }
  },
  "totalElements": 5000,
  "totalPages": 250,
  "last": false,
  "numberOfElements": 20,
  "first": true,
  "empty": false
}

For endpoints that return very large datasets (think: analytics over months of trading data), the totalElements count itself becomes expensive. Some databases need to scan the entire result set to give an accurate count. In those cases, consider a cursor-based response instead:

java
public record PaginatedResponse<T>(
    List<T> content,
    String nextCursor,
    boolean hasMore) {}

@GetMapping
public ResponseEntity<PaginatedResponse<OrderDTO>> listOrders(
    @RequestParam(required = false) String cursor) {
  
  int limit = 20;
  List<Order> orders = orderRepository.findAfterCursor(cursor, limit + 1);
  
  boolean hasMore = orders.size() > limit;
  List<Order> content = orders.stream()
      .limit(limit)
      .toList();
  
  String nextCursor = hasMore 
      ? Base64.getEncoder().encodeToString(
          String.valueOf(content.get(content.size() - 1).getId()).getBytes())
      : null;
  
  return ResponseEntity.ok(new PaginatedResponse<>(
      content.stream().map(this::toDTO).toList(),
      nextCursor,
      hasMore));
}

This approach avoids the count query entirely. You only know if there's a next page by fetching one extra record. It's the pattern Instagram, Twitter, and Stripe use. Clients get a nextCursor instead of a page number, and they pass it back to get the next batch.

Use Keyset Pagination for Deep Pages

Offset-based pagination (the default with Pageable) becomes slow when users request page 10,000. The database still has to skip over 200,000 rows before returning results. For deep pagination—common in admin dashboards and data exports—keyset pagination is faster and more stable.

Keyset pagination sorts by a unique identifier (usually the primary key) and uses the last record from the previous page as the cursor:

java
@GetMapping("/keyset")
public ResponseEntity<List<OrderDTO>> listOrdersKeyset(
    @RequestParam(required = false) Long lastId,
    @RequestParam(defaultValue = "20") int size) {
  
  List<Order> orders = orderRepository.findOrdersAfter(lastId, size + 1);
  
  boolean hasMore = orders.size() > size;
  List<OrderDTO> content = orders.stream()
      .limit(size)
      .map(this::toDTO)
      .toList();
  
  return ResponseEntity.ok(content);
}

The repository method looks like this:

java
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
  
  @Query("SELECT o FROM Order o WHERE o.id > COALESCE(:lastId, 0) ORDER BY o.id ASC LIMIT :size")
  List<Order> findOrdersAfter(@Param("lastId") Long lastId, @Param("size") int size);
}

The query only needs to scan records after the last ID. No massive offset. This scales linearly regardless of how many pages deep a user goes.

Common Mistakes

Forgetting to set a default page size: If you rely on client-provided sizes without a cap, you're vulnerable. Always set spring.data.web.pageable.default-page-size and max-page-size.

Returning full entity objects in paginated responses: Don't map your Page<Entity> directly to JSON. Use DTOs. Entities often contain lazy-loaded relationships that trigger extra queries or expose sensitive fields. Always use page.map(this::toDTO).

Mixing offset and keyset pagination inconsistently: Pick one strategy per endpoint and stick with it. Keyset is faster for deep pages; offset is simpler for UIs that need "go to page X." Don't switch mid-flight.

Ignoring the cost of count queries: Page<T> counts all matching records by default. For huge tables, this is slow. Use Slice<T> instead if you only need to know if there's a next page, or implement cursor-based pagination.

Key Takeaways

  • Always use Pageable in controller methods to parse pagination parameters safely.
  • Cap max page size globally via spring.data.web.pageable.max-page-size to prevent memory exhaustion.
  • Return Page<T> for most use cases; use cursor-based responses for very large datasets.
  • Apply keyset pagination for deep pages in admin interfaces and data exports.
  • Map entities to DTOs before returning paginated results to avoid lazy-loading surprises.
  • Never leave pagination unbounded—it's a production incident waiting to happen.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles