Pagination, Sorting and Filtering in Spring Boot REST APIs
Introduction
This tutorial adds pagination, sorting and filtering to a Spring Boot REST API, so that clients can work with collections far too large to return in one response. We will start with why a list endpoint should never return everything, and the parameters clients commonly use -page, size, sort and filters. We will then build a paginated book endpoint that returns a page envelope with the totals, plus the standard Link header with the URLs of the next and previous pages. Next we will sort by one or more fields in either direction, safely, and filter with a group of optional parameters. We will then protect the API with limits on page size, compare offset pagination with cursor (keyset) pagination, and lastly preview how Spring Data's Pageable does all this for database queries.(This tutorial uses query parameters and
@ModelAttribute from Spring MVC Request MappingClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies, and the validation from Bean Validation in Spring BootClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators.)
Why paginate?
AGET /api/books that returns every book works while there are ten. With a hundred thousand it becomes slow for the database, heavy on server memory, large on the network and useless to a person scrolling a screen. A list endpoint should therefore return one page at a time, and let the client choose which page, how big, in what order, and which items -
page: which page, counted from 0 (or 1 - choose one and document it).size: how many items per page, with a default and a maximum.sort: one or more fields with a direction, such assort=year,desc.- filters:
author=Bloch,fromYear=2010and so on.
The paginated endpoint
The example keeps its books in memory, so that it can show the HTTP side on its own; the database version comes with Spring Data in module 4 of this series -PagingApp.java - page, size, sort and filters on a list endpoint
//PagingApp.java
package com.javabrahman.rest.paging;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
record Book(long id, String title, String author, int year, BigDecimal price) {
}
// The page envelope returned to clients
record PageResponse<T>(List<T> content, int page, int size, long totalElements, int totalPages) {
}
// Optional filters, bound from query parameters
record BookFilter(String author, Integer fromYear, Integer toYear, BigDecimal maxPrice) {
boolean matches(Book b) {
return (author == null || b.author().toLowerCase().contains(author.toLowerCase()))
&& (fromYear == null || b.year() >= fromYear)
&& (toYear == null || b.year() <= toYear)
&& (maxPrice == null || b.price().compareTo(maxPrice) <= 0);
}
}
@RestController
@RequestMapping("/api/books")
class BookController {
private static final List<Book> BOOKS = List.of(
new Book(1, "Effective Java", "Joshua Bloch", 2018, new BigDecimal("45.99")),
new Book(2, "Java Concurrency in Practice", "Brian Goetz", 2006, new BigDecimal("49.99")),
new Book(3, "Modern Java in Action", "Raoul-Gabriel Urma", 2018, new BigDecimal("39.99")),
new Book(4, "Clean Code", "Robert C. Martin", 2008, new BigDecimal("37.49")),
new Book(5, "Head First Design Patterns", "Eric Freeman", 2020, new BigDecimal("54.99")),
new Book(6, "Java Puzzlers", "Joshua Bloch", 2005, new BigDecimal("29.99")),
new Book(7, "Spring in Action", "Craig Walls", 2022, new BigDecimal("49.99")),
new Book(8, "Refactoring", "Martin Fowler", 2018, new BigDecimal("47.99")),
new Book(9, "The Pragmatic Programmer", "David Thomas", 2019, new BigDecimal("42.49")),
new Book(10, "Domain-Driven Design", "Eric Evans", 2003, new BigDecimal("59.99")),
new Book(11, "Java Performance", "Scott Oaks", 2020, new BigDecimal("44.99")),
new Book(12, "Effective Unit Testing", "Lasse Koskela", 2013, new BigDecimal("34.99")));
// Only these fields may be sorted on - never sort by a raw client-supplied name
private static final Map<String, Comparator<Book>> SORTABLE = Map.of(
"id", Comparator.comparingLong(Book::id),
"title", Comparator.comparing(Book::title),
"year", Comparator.comparingInt(Book::year),
"price", Comparator.comparing(Book::price));
@GetMapping
ResponseEntity<PageResponse<Book>> list(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "5") @Min(1) @Max(50) int size,
@ModelAttribute BookFilter filter,
WebRequest request) {
// the raw sort values, each "field" or "field,direction" (see the explanation below)
String[] sort = request.getParameterValues("sort");
List<Book> matching = new ArrayList<>(BOOKS.stream().filter(filter::matches).toList());
matching.sort(comparator(sort == null ? new String[] {"id,asc"} : sort));
int totalPages = (int) Math.ceil(matching.size() / (double) size);
int from = Math.min(page * size, matching.size());
List<Book> content = matching.subList(from, Math.min(from + size, matching.size()));
return ResponseEntity.ok()
.header("X-Total-Count", String.valueOf(matching.size()))
.header("Link", links(page, totalPages))
.body(new PageResponse<>(content, page, size, matching.size(), totalPages));
}
// sort=year,desc&sort=title -> by year descending, then by title ascending
private Comparator<Book> comparator(String[] sort) {
Comparator<Book> result = null;
for (String value : sort) {
String[] parts = value.split(",");
String field = parts[0].trim();
boolean desc = parts.length > 1 && parts[1].trim().equalsIgnoreCase("desc");
Comparator<Book> c = SORTABLE.get(field);
if (c == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot sort by '" + field + "'. Sortable fields: " + SORTABLE.keySet().stream().sorted().toList());
}
c = desc ? c.reversed() : c;
result = result == null ? c : result.thenComparing(c);
}
return result.thenComparing(Comparator.comparingLong(Book::id)); // a stable order for equal values
}
// RFC 8288 Link header: first, prev, next, last
private String links(int page, int totalPages) {
Function<Integer, String> url = p -> ServletUriComponentsBuilder.fromCurrentRequest()
.replaceQueryParam("page", p).build().toUriString();
List<String> links = new ArrayList<>();
links.add("<" + url.apply(0) + ">; rel=\"first\"");
if (page > 0) { // past the end, prev points to the last real page
links.add("<" + url.apply(Math.min(page - 1, Math.max(totalPages - 1, 0))) + ">; rel=\"prev\"");
}
if (page + 1 < totalPages) {
links.add("<" + url.apply(page + 1) + ">; rel=\"next\"");
}
links.add("<" + url.apply(Math.max(totalPages - 1, 0)) + ">; rel=\"last\"");
return String.join(", ", links);
}
}
@SpringBootApplication
public class PagingApp {
public static void main(String[] args) {
SpringApplication.run(PagingApp.class, args);
}
}
Paging through the books
The first page with the default size of 5, then the second page, then the last one - OUTPUT of the first, second and last pages> curl -i http://localhost:8080/api/books
HTTP/1.1 200
X-Total-Count: 12
Link: <http://localhost:8080/api/books?page=0>; rel="first", <http://localhost:8080/api/books?page=1>; rel="next", <http://localhost:8080/api/books?page=2>; rel="last"
Content-Type: application/json
{"content":[{"id":1,"title":"Effective Java","author":"Joshua Bloch","year":2018,"price":45.99},{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","year":2006,"price":49.99},{"id":3,"title":"Modern Java in Action","author":"Raoul-Gabriel Urma","year":2018,"price":39.99},{"id":4,"title":"Clean Code","author":"Robert C. Martin","year":2008,"price":37.49},{"id":5,"title":"Head First Design Patterns","author":"Eric Freeman","year":2020,"price":54.99}],"page":0,"size":5,"totalElements":12,"totalPages":3}
> curl -i http://localhost:8080/api/books?page=1
HTTP/1.1 200
X-Total-Count: 12
Link: <http://localhost:8080/api/books?page=0>; rel="first", <http://localhost:8080/api/books?page=0>; rel="prev", <http://localhost:8080/api/books?page=2>; rel="next", <http://localhost:8080/api/books?page=2>; rel="last"
Content-Type: application/json
{"content":[{"id":6,"title":"Java Puzzlers","author":"Joshua Bloch","year":2005,"price":29.99},{"id":7,"title":"Spring in Action","author":"Craig Walls","year":2022,"price":49.99},{"id":8,"title":"Refactoring","author":"Martin Fowler","year":2018,"price":47.99},{"id":9,"title":"The Pragmatic Programmer","author":"David Thomas","year":2019,"price":42.49},{"id":10,"title":"Domain-Driven Design","author":"Eric Evans","year":2003,"price":59.99}],"page":1,"size":5,"totalElements":12,"totalPages":3}
> curl -i http://localhost:8080/api/books?page=2
HTTP/1.1 200
X-Total-Count: 12
Link: <http://localhost:8080/api/books?page=0>; rel="first", <http://localhost:8080/api/books?page=1>; rel="prev", <http://localhost:8080/api/books?page=2>; rel="last"
Content-Type: application/json
{"content":[{"id":11,"title":"Java Performance","author":"Scott Oaks","year":2020,"price":44.99},{"id":12,"title":"Effective Unit Testing","author":"Lasse Koskela","year":2013,"price":34.99}],"page":2,"size":5,"totalElements":12,"totalPages":3}
Explanation of the output
- The envelope -
content,page,size,totalElements,totalPages- tells the client what it got and how much there is: 12 books in 3 pages of 5. The last page held the remaining two. X-Total-Countrepeats the total in a header, a common convention that lets clients read it without parsing the body.- The
Linkheader (RFC 8288) carries ready-made URLs for thefirst,prev,nextandlastpages. Clients follow them instead of building URLs; the first page has noprevand the last nonext.replaceQueryParam()keeps every other query parameter, so the links preserve the client's sort and filters.
Sorting
sort takes a field and an optional direction, and may be repeated for secondary orders. Sorting by year, newest first, and then by price; and an attempt to sort by a field that is not allowed -
OUTPUT of sorted requests> curl -i http://localhost:8080/api/books?sort=year,desc&sort=price&size=6
HTTP/1.1 200
X-Total-Count: 12
Link: <http://localhost:8080/api/books?sort=year,desc&sort=price&size=6&page=0>; rel="first", <http://localhost:8080/api/books?sort=year,desc&sort=price&size=6&page=1>; rel="next", <http://localhost:8080/api/books?sort=year,desc&sort=price&size=6&page=1>; rel="last"
Content-Type: application/json
{"content":[{"id":7,"title":"Spring in Action","author":"Craig Walls","year":2022,"price":49.99},{"id":11,"title":"Java Performance","author":"Scott Oaks","year":2020,"price":44.99},{"id":5,"title":"Head First Design Patterns","author":"Eric Freeman","year":2020,"price":54.99},{"id":9,"title":"The Pragmatic Programmer","author":"David Thomas","year":2019,"price":42.49},{"id":3,"title":"Modern Java in Action","author":"Raoul-Gabriel Urma","year":2018,"price":39.99},{"id":1,"title":"Effective Java","author":"Joshua Bloch","year":2018,"price":45.99}],"page":0,"size":6,"totalElements":12,"totalPages":2}
> curl -i http://localhost:8080/api/books?sort=author
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Cannot sort by 'author'. Sortable fields: [id, price, title, year]","instance":"/api/books","status":400,"title":"Bad Request"}
Explanation of the output
- Each
sortvalue isfieldorfield,direction- the format Spring Data uses too. The controller reads the raw values withWebRequest.getParameterValues("sort")and splits each one itself. A@RequestParam List<String> sortwould be unreliable here: Spring splits a single comma-separated value into list elements (sort=year,descbecomes[year, desc]), but leaves repeated values intact (sort=year,desc&sort=pricebecomes[year,desc, price]), so the meaning would change with the number of sort parameters. - The comparator built from the values sorted by year descending, then by price ascending - visible in the two books from 2020 and the two from 2018, each pair in order of price.
- A final comparison by id makes the order stable. Without it, items with equal sort values could change order between requests, and a client paging through could see an item twice or miss one.
authoris not in theSORTABLEmap, so the request was rejected with a400problem naming the fields that can be used. Mapping client-supplied names to a fixed set of comparators - or, with a database, to known column names - prevents sorting on internal fields, and is essential protection against injection when building queries.
Filtering
The filters are optional query parameters bound to theBookFilter record, and they combine freely with paging and sorting -
OUTPUT of filtered requests> curl -i http://localhost:8080/api/books?author=bloch
HTTP/1.1 200
X-Total-Count: 2
Link: <http://localhost:8080/api/books?author=bloch&page=0>; rel="first", <http://localhost:8080/api/books?author=bloch&page=0>; rel="last"
Content-Type: application/json
{"content":[{"id":1,"title":"Effective Java","author":"Joshua Bloch","year":2018,"price":45.99},{"id":6,"title":"Java Puzzlers","author":"Joshua Bloch","year":2005,"price":29.99}],"page":0,"size":5,"totalElements":2,"totalPages":1}
> curl -i http://localhost:8080/api/books?fromYear=2018&maxPrice=45&sort=price,desc&size=2
HTTP/1.1 200
X-Total-Count: 3
Link: <http://localhost:8080/api/books?fromYear=2018&maxPrice=45&sort=price,desc&size=2&page=0>; rel="first", <http://localhost:8080/api/books?fromYear=2018&maxPrice=45&sort=price,desc&size=2&page=1>; rel="next", <http://localhost:8080/api/books?fromYear=2018&maxPrice=45&sort=price,desc&size=2&page=1>; rel="last"
Content-Type: application/json
{"content":[{"id":11,"title":"Java Performance","author":"Scott Oaks","year":2020,"price":44.99},{"id":9,"title":"The Pragmatic Programmer","author":"David Thomas","year":2019,"price":42.49}],"page":0,"size":2,"totalElements":3,"totalPages":2}
next link carries all of those parameters on to page 1. Each filter that is not sent is null in the record and simply ignored.
Limits that protect the API
A client - by mistake or not - could ask for a million items per page. Thesize parameter is therefore constrained with @Min(1) @Max(50), and page with @Min(0) -
OUTPUT of requests outside the limits> curl -i http://localhost:8080/api/books?size=1000
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Validation failure","instance":"/api/books","status":400,"title":"Bad Request"}
> curl -i http://localhost:8080/api/books?page=-1
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Validation failure","instance":"/api/books","status":400,"title":"Bad Request"}
> curl -i http://localhost:8080/api/books?page=7
HTTP/1.1 200
X-Total-Count: 12
Link: <http://localhost:8080/api/books?page=0>; rel="first", <http://localhost:8080/api/books?page=2>; rel="prev", <http://localhost:8080/api/books?page=2>; rel="last"
Content-Type: application/json
{"content":[],"page":7,"size":5,"totalElements":12,"totalPages":3}
400 problems. A page beyond the end, such as page 7, is not an error: it returns an empty content with the correct totals, and its prev link points back into range, so clients can detect that they have gone past the end.
Offset pagination vs cursor pagination
The example uses offset pagination: page n skips n × size items. It is simple, and clients can jump to any page. With a database, it has two weaknesses:OFFSET 100000 still makes the database read and discard 100,000 rows, and if items are inserted or deleted while a client pages through, items shift between pages.Cursor (or keyset) pagination avoids both. Instead of a page number, each response returns a cursor - typically the sort values of its last item, often encoded as an opaque string - and the client asks for "the items after this cursor":
GET /api/books?size=5&after=eyJpZCI6NX0. The query becomes WHERE id > 5 ORDER BY id LIMIT 5, which a database answers quickly from an index at any depth, and new items never shift the pages already read. The cost: no jumping to page 50, and usually no total count. Use offsets for admin screens and small collections; use cursors for feeds, infinite scrolling, and large or fast-changing data.
With Spring Data: Pageable
With a database and Spring Data, most of this code disappears. Spring Data's web support bindspage, size and sort parameters, in exactly the format used above, into a Pageable; repository methods accept it and return a Page with the content and totals, computed by the database -
The same endpoint with Spring Data (module 4)
@GetMapping
PagedModel<Book> list(@PageableDefault(size = 5, sort = "id") Pageable pageable) {
return new PagedModel<>(bookRepository.findAll(pageable)); // SELECT ... ORDER BY ... LIMIT ... OFFSET ...
}
spring.data.web.pageable.max-page-size property caps the page size, as our @Max(50) did. Spring Data also supports cursor-style paging with its Window and ScrollPosition types. Both are covered in module 4, on Spring Data JPA.
Summary
In this tutorial we first saw why list endpoints must be paginated, and the usualpage, size, sort and filter parameters. We then built a book endpoint that returns a page envelope with totals, an X-Total-Count header and an RFC 8288 Link header, and paged through it. Next we sorted by several fields in either direction, with a stable order and a fixed set of sortable fields, and filtered with optional parameters bound to a record. We then enforced limits on page size with validation, compared offset and cursor pagination, and lastly previewed Spring Data's Pageable. The next tutorial takes the other side of the conversation and calls REST APIs: RestClient and HTTP interfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces.Spring REST API Tutorials
API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail
API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot Bean Validation in Spring Boot: @Valid and Custom ValidatorsClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail