Spring Boot Course · Lesson 8

Pagination & Sorting

GET /api/books returns every book in one response. With a dozen seeded rows that's fine; with fifty thousand it's a slow query, a huge JSON payload, and a server straining to hold it all in memory at once. In this lesson you'll hand the client a steering wheel — ask for one page at a time, in the order you want — by changing almost nothing in your code.

The win

Add a single Pageable parameter and your list endpoints accept ?page=&size=&sort= for free — Spring Data does the LIMIT/OFFSET/ORDER BY for you. Responses come back as a clean Page: the slice of rows plus the metadata a UI needs (totalElements, totalPages, current page). You'll also fix a subtle trap — the default page JSON is officially unstable — with one property, and cap the page size so a client can't ask for a million rows.

Why an unbounded list is a bug waiting to happen

A collection endpoint that always returns the whole table has three problems that all grow with your data:

Pagination fixes all three by pushing a LIMIT and OFFSET down to the database: fetch page 0, 20 rows, then page 1, and so on. Spring Data has this built into every repository — you mostly just have to ask for it.

Step 1 — Add a book that ties on year

The database you bring from Lesson 7 holds five books and five authors, and every book has a different year. That matters here: the first request in this lesson sorts by year, and a sort key that two rows share is exactly what makes pages overlap — the trap Step 6 closes. So add a second book from 2018 to create that tie, through the API you already have. Start the app as it stands at the end of Lesson 7, then create the author first, because the service refuses a book whose authorIds it can't resolve:

Terminal window
curl -i -X POST http://localhost:8080/api/authors \
-H "Content-Type: application/json" -d '{"name":"Martin Fowler"}'
HTTP/1.1 201
Location: /api/authors/01a0d707-3243-7631-b7fb-3925fbf169a6
Content-Type: application/json
{"id":"01a0d707-3243-7631-b7fb-3925fbf169a6","name":"Martin Fowler"}

Now his book, with the id just returned — as always, copy it from your response, not from the page:

Terminal window
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Refactoring","year":2018,"authorIds":["01a0d707-3243-7631-b7fb-3925fbf169a6"]}'
HTTP/1.1 201
Location: /api/books/01a0d707-32de-7f35-a606-62b04f032ecd
Content-Type: application/json
{"id":"01a0d707-32de-7f35-a606-62b04f032ecd","title":"Refactoring","year":2018,
"authors":[{"id":"01a0d707-3243-7631-b7fb-3925fbf169a6","name":"Martin Fowler"}]}

Six books, two of them from 2018. Stop the app — the next steps change code, and Step 4 starts it again.

Step 2 — Accept a Pageable

Pageable is Spring Data's bundle of "which page, how big, sorted how." Add it as a controller parameter and Spring builds it automatically from the page, size, and sort query parameters — no @RequestParam wiring needed. The repository's inherited findAll(Pageable) then returns a Page instead of a List:

src/main/java/com/example/library/book/BookController.java
5 collapsed lines
package com.example.library.book;
import java.net.URI;
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
23 collapsed lines
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@GetMapping // GET /api/books?authorId=...&page=0&size=20&sort=title
public Page<BookResponse> all(
@RequestParam(required = false) UUID authorId,
@PageableDefault(size = 20, sort = "title") Pageable pageable) {
return books.findAll(authorId, pageable).map(BookResponse::from);
}
23 collapsed lines
@GetMapping("/{id}")
public BookResponse byId(@PathVariable UUID id) {
return BookResponse.from(books.getById(id));
}
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@PutMapping("/{id}")
public BookResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
return BookResponse.from(books.replace(id, req.title(), req.year(), req.authorIds()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 on success
public void delete(@PathVariable UUID id) {
books.delete(id); // throws NotFoundException -> 404
}
}

The filter from Lesson 6 (?authorId=) is still here — paging and filtering compose. The new imports are org.springframework.data.domain.Page, org.springframework.data.domain.Pageable, and org.springframework.data.web.PageableDefault; java.util.List can go, because nothing in the controller returns one any more (more on @PageableDefault in Step 6).

Push the Pageable down through the layers. The service keeps deciding which query to run (the Lesson 3 rule — the service decides, the controller translates), now handing the page request to whichever repository method it picks:

src/main/java/com/example/library/book/BookService.java
9 collapsed lines
package com.example.library.book;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.example.library.author.Author;
import com.example.library.author.AuthorRepository;
import com.example.library.error.NotFoundException;
import org.springframework.data.domain.Page; // new, both of them
import org.springframework.data.domain.Pageable;
13 collapsed lines
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository books;
private final AuthorRepository authors; // new: to resolve author ids
public BookService(BookRepository books, AuthorRepository authors) {
this.books = books;
this.authors = authors;
}
public Page<Book> findAll(UUID authorId, Pageable pageable) {
return (authorId == null)
? books.findAll(pageable) // inherited from JpaRepository
: books.findByAuthorsId(authorId, pageable); // our derived query, now paged
}
37 collapsed lines
// Replaces findById: the service now decides "not found" and throws instead of returning an Optional.
public Book getById(UUID id) {
return books.findById(id)
.orElseThrow(() -> new NotFoundException("No book with id " + id));
}
public Book create(String title, int year, Set<UUID> authorIds) {
Book book = new Book(title, year, requireAuthors(authorIds));
return books.save(book);
}
public Book replace(UUID id, String title, int year, Set<UUID> authorIds) {
if (!books.existsById(id)) {
throw new NotFoundException("No book with id " + id);
}
Book book = new Book(title, year, requireAuthors(authorIds));
book.setId(id);
return books.save(book);
}
public void delete(UUID id) {
if (!books.existsById(id)) {
throw new NotFoundException("No book with id " + id);
}
books.deleteById(id);
}
// Every id must resolve to a real author, or the book isn't created at all.
private Set<Author> requireAuthors(Set<UUID> authorIds) {
List<Author> found = authors.findAllById(authorIds);
if (found.size() != authorIds.size()) {
Set<UUID> missing = new HashSet<>(authorIds);
found.forEach(a -> missing.remove(a.getId()));
throw new NotFoundException("No author with id " + missing);
}
return new HashSet<>(found);
}
}

findAll(Pageable) already exists on JpaRepository. For the derived query, just add a Pageable parameter and switch the return type to Page — Spring Data rewrites the rest:

src/main/java/com/example/library/book/BookRepository.java
package com.example.library.book;
import java.util.UUID; // List is gone: the derived query returns a Page now
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, UUID> {
// Traverses the collection: WHERE any of the book's authors has this id, one page at a time
Page<Book> findByAuthorsId(UUID authorId, Pageable pageable);
// Is any book still linked to this author? (Spring Data derives an EXISTS query.)
boolean existsByAuthorsId(UUID authorId);
}
Page vs. Slice vs. List

A repository paging method can return three shapes. Page<T> is the rich one: the rows plus a second COUNT query so it knows totalElements and totalPages — what you need to render "Page 2 of 9." Slice<T> skips the count (cheaper) and only knows whether a next page exists — good for "Load more" infinite scroll. A plain List<T> honours the paging but drops all metadata. We use Page: with the totals in the response, whoever calls the API can show "Page 2 of 9" or "6 books in total" — a Slice only lets them offer "Load more".

Step 3 — Map the page to DTOs

Notice the controller never builds a Page by hand — it calls .map(BookResponse::from). Page is a functor: map transforms each element while carrying all the metadata across unchanged. So Page<Book> becomes Page<BookResponse> — the same Lesson 6 rule (entities never cross the boundary; map to a DTO at the edge), now one page at a time. The service and repository still speak entities; only the controller maps.

Step 4 — Run it: page and sort from the URL

Restart the app. Three query parameters now steer the endpoint — and you didn't write code for any of them:

Terminal window
# Page 0, three per page, newest first
curl -s "http://localhost:8080/api/books?size=3&page=0&sort=year,desc"

The books come back in the right order and the response carries the paging metadata — but look closely at the shape of that metadata:

{
"content": [
{ "id": "01a0d23d-81cd-…", "title": "Effective Java", "year": 2018, "authors": [ { "id": "…", "name": "Joshua Bloch" } ] },
{ "id": "01a0d707-32de-…", "title": "Refactoring", "year": 2018, "authors": [ { "id": "…", "name": "Martin Fowler" } ] },
{ "id": "01a0d23f-3f2d-…", "title": "Clean Architecture", "year": 2017, "authors": [ { "id": "…", "name": "Robert C. Martin" } ] }
],
"empty": false,
"first": true,
"last": false,
"number": 0,
"numberOfElements": 3,
"pageable": {
"offset": 0, "pageNumber": 0, "pageSize": 3, "paged": true,
"sort": { "empty": false, "sorted": true, "unsorted": false },
"unpaged": false
},
"size": 3,
"sort": { "empty": false, "sorted": true, "unsorted": false },
"totalElements": 6,
"totalPages": 2
}

That's a lot of sprawl — and worse, the server logged a warning the moment it serialized this:

WARN ... PageModule$WarningLoggingModifier :
Serializing PageImpl instances as-is is not supported, meaning that there is no
guarantee about the stability of the resulting JSON structure!
For a stable JSON structure, please use Spring Data's PagedModel …
Why returning a raw Page is a trap

Page's default implementation, PageImpl, is an internal domain type, not a designed API response. Jackson will happily serialize all its fields — but the Spring team makes no promise that this structure stays the same across versions. Build a client against pageable.pageNumber today and a future upgrade could rename or move it. The warning is Spring telling you: pin down a stable contract before you ship.

Step 5 — Stabilize the JSON with one property

The fix is a single property that switches Spring to a small, guaranteed-stable page representation called PagedModel. Your controllers keep returning Page<BookResponse> — Spring renders it in the stable shape automatically:

src/main/resources/application.properties
# Hibernate never alters the schema - Liquibase owns it. Just verify entities match the tables.
spring.jpa.hibernate.ddl-auto=validate
# Render Page results in the stable { content, page } shape (PagedModel) instead of raw PageImpl
spring.data.web.pageable.serialization-mode=via_dto
Why a property and not the annotation?

The reference docs also show the @EnableSpringDataWebSupport annotation with pageSerializationMode = VIA_DTO. On Spring Boot, that annotation replaces Boot's own Spring Data web auto-configuration — so every spring.data.web.pageable.* property, including the size cap you'll add in Step 6, is silently ignored (verified: ?size=99999 came back as 2000 with the annotation in place, not the lowered cap). The property keeps Boot's auto-configuration in charge, so both the stable shape and the size cap work together.

Restart and re-run the same request. The warning is gone, and the metadata collapses to exactly what a client needs — the rows under content, the paging facts under one tidy page object:

{
"content": [
{ "id": "01a0d23d-81cd-…", "title": "Effective Java", "year": 2018, "authors": [ { "id": "…", "name": "Joshua Bloch" } ] },
{ "id": "01a0d707-32de-…", "title": "Refactoring", "year": 2018, "authors": [ { "id": "…", "name": "Martin Fowler" } ] },
{ "id": "01a0d23f-3f2d-…", "title": "Clean Architecture", "year": 2017, "authors": [ { "id": "…", "name": "Robert C. Martin" } ] }
],
"page": {
"size": 3,
"number": 0,
"totalElements": 6,
"totalPages": 2
}
}

This is the contract to build a UI on: content is the current slice, page.totalPages drives the pager, page.number is where you are. Ask for the next page and only number (and the rows) change:

Terminal window
curl -s "http://localhost:8080/api/books?size=3&page=1&sort=year,desc"
# -> content = ["Clean Code", "Domain-Driven Design", "The Pragmatic Programmer"]
# page = { "size": 3, "number": 1, "totalElements": 6, "totalPages": 2 }

Step 6 — Sane defaults and a safety cap

Two production touches. First, @PageableDefault (already on the handler in Step 2) decides what an unparameterized request gets. Without it, a bare GET /api/books uses Spring's global default of 20 rows, unsorted; with it you set a sensible page size and a default sort so results never come back in arbitrary order:

@PageableDefault(size = 20, sort = "title") Pageable pageable

Second — and this one matters for safety — the page size is capped. Spring caps size at spring.data.web.pageable.max-page-size (default 2000). Lower it to something realistic for your API — a request for a million rows quietly becomes a request for, say, 100. It goes right below the serialization-mode property from Step 5, in the same file — all three paging settings live in one place:

src/main/resources/application.properties
# Hibernate never alters the schema - Liquibase owns it. Just verify entities match the tables.
spring.jpa.hibernate.ddl-auto=validate
# Render Page results in the stable { content, page } shape (PagedModel) instead of raw PageImpl
spring.data.web.pageable.serialization-mode=via_dto
# Cap how many rows one page request can pull
spring.data.web.pageable.max-page-size=100
# (optional) change the default page size for unparameterized requests
spring.data.web.pageable.default-page-size=20

Restart, and a client can't escape pagination by asking for everything:

Terminal window
curl -s "http://localhost:8080/api/books?size=99999"
# -> page = { "size": 100, "number": 0, "totalElements": 6, "totalPages": 1 }
# the requested 99999 was clamped to 100
Sort on something unique, or pages will shuffle — and make it the server's job

Pagination is just LIMIT/OFFSET over an ORDER BY. If the sort key isn't unique — say two books share a year — the database may order the ties differently between queries, so the same row can appear on page 1 and page 2, or vanish entirely. The cure is a tie-breaker on a unique column, giving a total order like ORDER BY year DESC, id ASC. But guaranteeing that order is a correctness invariant of your API — too important to depend on whether each client remembers to append &sort=id. Enforce it in the handler: take whatever sort arrives and append id if it isn't already there.

That guarantee is stateless and identical for every paged endpoint, so define it once as a small helper rather than inlining it in each controller (and copy-pasting it into the next). In package-by-feature, a cross-cutting utility earns its own home:

src/main/java/com/example/library/util/Pageables.java
package com.example.library.util;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public final class Pageables {
private Pageables() {} // utility class, never instantiated
// Append the unique id as a final sort key (if absent) so paging is deterministic.
public static Pageable withIdTiebreaker(Pageable pageable) {
Sort sort = pageable.getSort();
boolean hasId = sort.stream().anyMatch(o -> o.getProperty().equals("id"));
return hasId ? pageable
: PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort.and(Sort.by("id")));
}
}

Now the handler stays a one-liner — it just runs the incoming Pageable through the helper before querying. No new imports in the controller beyond Pageables itself:

src/main/java/com/example/library/book/BookController.java
5 collapsed lines
package com.example.library.book;
import java.net.URI;
import java.util.UUID;
import jakarta.validation.Valid;
import com.example.library.util.Pageables;
26 collapsed lines
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@GetMapping // GET /api/books?authorId=...&page=0&size=20&sort=title
public Page<BookResponse> all(
@RequestParam(required = false) UUID authorId,
@PageableDefault(size = 20, sort = "title") Pageable pageable) {
return books.findAll(authorId, Pageables.withIdTiebreaker(pageable)).map(BookResponse::from);
}
23 collapsed lines
@GetMapping("/{id}")
public BookResponse byId(@PathVariable UUID id) {
return BookResponse.from(books.getById(id));
}
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@PutMapping("/{id}")
public BookResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
return BookResponse.from(books.replace(id, req.title(), req.year(), req.authorIds()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 on success
public void delete(@PathVariable UUID id) {
books.delete(id); // throws NotFoundException -> 404
}
}

A client's ?sort=year,desc now runs as year DESC, id ASC; a request with no sort becomes title ASC, id ASC; and a client that already sorts by id is left untouched — no duplicate clause, and their chosen direction wins. The server's contract is simply "a unique key is always in the ORDER BY," not "id is forced to the end." And because our id is a time-ordered UUIDv7, that tiebreaker is also meaningful — a stable, insertion-ordered fallback, a quiet payoff of the Lesson 5 id choice.

Where the two responsibilities split

The client owns which field to sort by and which page to fetch — that's its UX. The server owns the guarantee that the resulting order is total and stable so paging stays honest, which is why it lives in your code, not the query string. One shared Pageables.withIdTiebreaker keeps that guarantee in a single place — every endpoint calls the same method. Its one weakness: a brand-new endpoint could forget to call it. When you have enough paged endpoints that this worries you, promote the helper to a custom Pageable argument-resolver — then every Pageable arrives already hardened and the call disappears from the controllers entirely.

Do the same for authors

GET /api/authors has the identical unbounded-list problem. The change is mechanical — mirror all of the above:

No repository change is needed — findAll(Pageable) is inherited. The serialization-mode property is global, so the authors endpoint gets the stable shape too.

Pagination caps the blast radius — it doesn't fix N+1

Each book in a page still lazily loads its authors set to build the nested author responses — so a page of 20 books fires up to 20 extra queries against the join table (the N+1 problem from Lesson 6, now bounded by page size instead of table size). Paging makes the damage finite, but the right fix — fetching the authors in one query — is the whole subject of the next lesson.

If it doesn't behave
  • sort=year,desc seems ignored? The sort field must be the entity property name (year, title), not the DB column or the JSON field. A typo'd property is silently dropped, leaving the result unsorted.
  • Getting the sprawling JSON with a logged warning? The spring.data.web.pageable.serialization-mode=via_dto property is missing from application.properties, or you edited the wrong properties file — it must be on the classpath at startup.
  • ?size=5000 only returns 2000 (or 100) rows? Working as intended — that's the max-page-size cap protecting the server.

Check yourself

Answer from memory before revealing.

You add a Pageable parameter to a controller method. Where do page, size, and sort come from?
Why does Spring warn against returning a raw Page (PageImpl) as JSON?
What does page.map(BookResponse::from) do to the paging metadata?
Two books share the same year and you sort only by year. What can go wrong across pages?

Read this next

Primary source: the Spring Data reference on web support & pagination — the authoritative description of Pageable resolution, the PageImpl serialization problem, and the VIA_DTO / PagedModel fix you used here. For a gentler walkthrough with more examples, Paging with Spring Boot (Reflectoring) is excellent.

Glossary: the terms this lesson introduces are in the Glossary, under Lesson 8 — your quick-reference for every term we use.

Architecture decisions: the 5 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 8.

Code: the project at the end of this lesson · what changed in this lesson, exercises included.

💬 Discuss