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
| Type | Purpose |
|---|---|
Pageable | Input: 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) |
PageRequest | Concrete 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)sort—field,direction(direction:ascordesc, 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
Pageableis injected from query params (page,size,sort); use@PageableDefaultfor per-endpoint defaultsPage<T>includes total count;Slice<T>skips the count query (better for large tables)- Build a custom
PagedResponsewrapper to control the JSON shape - Cap
maxPageSizeto 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.