REST API · Lesson 39 of 95
Pagination and Sorting
Learn pagination and sorting in Spring Boot with Pageable, Page and PagedModel. Build a paged book catalog API, then sort by any field with a live example.
Imagine a library with twelve thousand books. A visitor asks, "Show me your books." Would the librarian carry every single book to the desk? Of course not. She would bring one trolley with a few books, and say, "Want the next trolley?" That trolley is a page. Your API should behave the same way.
That idea is called pagination, and sorting decides the order of the trolley. Let's see how Spring Boot splits big lists into pages, how sorting fits in, and how to build a small book catalog that answers with one page at a time.
What is Pagination and Sorting?
Every page has two settings:
- Page number. Which slice you want. Spring counts from 0, so the first page is page 0.
- Page size. How many records sit on one page.
Sorting adds a third setting: the property name and the direction, asc for smallest first or desc for biggest first.
Spring Data gives these ideas three small types. Pageable describes the request (page, size, sort). Page holds one slice of results plus the totals. Sort describes the ordering. You will use all three in this guide.
Why is it used?
Returning everything looks fine while you test with ten rows. In real life it hurts:
- Slow replies. The database reads every row and Jackson turns every row into JSON.
- Big memory use. A list of a million objects can crash your server.
- Wasted data. A phone screen shows twenty books. Why send twenty thousand?
- Unfair load. One careless client can slow down everyone else.
With pages, the database does LIMIT and OFFSET work for you, replies stay small, and clients get a clear "page 2 of 6" message that is easy to show as page buttons.
How it works
Here is the journey of a request for page 1, with two books per page, sorted by page count.
textClient | ?page=1&size=2 | &sort=pages,desc v Argument resolver builds a Pageable object | v BookController.list(pageable) | v BookRepository.findAll(pageable) | SQL with ORDER BY, LIMIT v Page<Book> (slice + totals) | v PagedModel -> JSON response
Spring MVC reads the three query parameters and builds a Pageable before your method runs. You never parse page or size yourself. You hand the Pageable to the repository, and Spring Data writes the SQL with ORDER BY and a row limit. It also runs a second small query to count all rows, so the Page knows the total. Last, you wrap the Page in a PagedModel, which Jackson turns into clean JSON.
Why PagedModel? A raw PageImpl has no stable JSON shape, and Spring Data warns you about it. PagedModel always gives the same shape: a content array and a page object with the numbers.
Real-Life Example
Think of a cinema's online booking page. It shows eight films per screen, sorted by release date with the newest first. You press "Next" and the site shows the next eight. Behind the scenes, the page asks the server for page 1, size 8, sorted by release date with the newest first. The server never sends all two hundred films. It also tells the page there are 25 pages in total, so the page can draw the buttons.
Code Example
Let's build BookNest, a small library catalog. It stores twelve books in an H2 in-memory database and serves them page by page. Add the web starter, the JPA starter and the H2 driver.
textbooknest-catalog/ ├─ pom.xml └─ src/main/ ├─ java/com/booknest/catalog/ │ ├─ CatalogApplication.java │ ├─ Book.java │ ├─ BookRepository.java │ └─ BookController.java └─ resources/ └─ application.properties
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.booknest</groupId> <artifactId>catalog</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: CatalogApplication.java in package com.booknest.catalog
javapackage com.booknest.catalog; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class CatalogApplication { public static void main(String[] args) { SpringApplication.run(CatalogApplication.class, args); } @Bean CommandLineRunner loadBooks(BookRepository books) { return args -> { books.save(new Book("Monsoon Diaries", "Asha Rao", 320)); books.save(new Book("The Tea Estate", "Neel Kumar", 280)); books.save(new Book("River Songs", "Asha Rao", 210)); books.save(new Book("Night Train to Pune", "Vikram Sen", 350)); books.save(new Book("The Lost Lighthouse", "Meera Iyer", 400)); books.save(new Book("Clay and Fire", "Neel Kumar", 190)); books.save(new Book("A Winter in Shimla", "Meera Iyer", 260)); books.save(new Book("Salt Road", "Vikram Sen", 300)); books.save(new Book("Paper Boats", "Asha Rao", 150)); books.save(new Book("The Last Ferry", "Neel Kumar", 330)); books.save(new Book("Mango Summers", "Meera Iyer", 240)); books.save(new Book("Temple Bells", "Vikram Sen", 275)); }; } }
File: Book.java in package com.booknest.catalog
javapackage com.booknest.catalog; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; @Entity public class Book { @Id @GeneratedValue private Long id; private String title; private String author; private int pages; protected Book() { } public Book(String title, String author, int pages) { this.title = title; this.author = author; this.pages = pages; } public Long getId() { return id; } public String getTitle() { return title; } public String getAuthor() { return author; } public int getPages() { return pages; } }
File: BookRepository.java in package com.booknest.catalog
javapackage com.booknest.catalog; 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, Long> { Page<Book> findByAuthor(String author, Pageable pageable); }
File: BookController.java in package com.booknest.catalog
javapackage com.booknest.catalog; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PagedModel; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/books") public class BookController { private final BookRepository books; public BookController(BookRepository books) { this.books = books; } @GetMapping public PagedModel<Book> list(@PageableDefault(size = 5, sort = "title") Pageable pageable) { Page<Book> page = books.findAll(pageable); return new PagedModel<>(page); } @GetMapping("/by-author") public PagedModel<Book> byAuthor(@RequestParam String author, Pageable pageable) { return new PagedModel<>(books.findByAuthor(author, pageable)); } }
File: application.properties in src/main/resources
propertiesspring.data.web.pageable.max-page-size=20 spring.jpa.open-in-view=false
Start the app and ask for the second page, two books per page:
bashmvn spring-boot:run curl "http://localhost:8080/books?size=2&page=1"
Output:
json{ "content": [ { "title": "Mango Summers", "author": "Meera Iyer", "pages": 240, "id": 11 }, { "title": "Monsoon Diaries", "author": "Asha Rao", "pages": 320, "id": 1 } ], "page": { "size": 2, "number": 1, "totalElements": 12, "totalPages": 6 } }
The reply is spaced out here so it is easy to read; curl prints it on one line. The default sort is by title, so page 1 holds the third and fourth titles. Now sort by page count, biggest first:
bashcurl "http://localhost:8080/books?size=2&sort=pages,desc"
Output:
json{ "content": [ { "title": "The Lost Lighthouse", "author": "Meera Iyer", "pages": 400, "id": 5 }, { "title": "Night Train to Pune", "author": "Vikram Sen", "pages": 350, "id": 4 } ], "page": { "size": 2, "number": 0, "totalElements": 12, "totalPages": 6 } }
Code Explained
@Entityand@IdturnBookinto a table row. Hibernate creates the table in H2 at startup.BookRepositoryextendsJpaRepository, sofindAll(Pageable)already exists. We addfindByAuthor(String, Pageable), and Spring Data writes the query from the method name.@PageableDefault(size = 5, sort = "title")sets defaults for when the client sends nothing. Without it, the default size is 20.Pageableas a method argument is filled by Spring MVC frompage,sizeandsort.new PagedModel<>(page)wraps thePagein a stable JSON shape.- The
max-page-sizesetting in thespring.data.web.pageablegroup caps the size. A client asking forsize=100quietly gets 20. CommandLineRunnerinCatalogApplicationsaves twelve books once at startup so you have data to page through.
Pageable at a Glance
| Request | Meaning |
|---|---|
?page=0&size=5 | First five records |
?page=2&size=5 | Records 11 to 15 |
?sort=title | Sort by title, ascending |
?sort=pages,desc | Sort by pages, biggest first |
?sort=author&sort=title | Sort by author, then title |
| No parameters | Uses @PageableDefault, else page 0 and size 20 |
Inside the reply, totalElements is the count of all records and totalPages is how many pages that makes. A page number past the end is not an error. You get an empty content array, as shown here for page=9:
json{ "content": [], "page": { "size": 2, "number": 9, "totalElements": 12, "totalPages": 6 } }
Building Pageable by Hand
Sometimes you want your own parameter names, or you must check them first. Then build the request yourself with PageRequest.of(page, size, sort). The practice section below does exactly that, with a list of allowed sort fields.
That failure looks like this:
json{ "timestamp": "2026-09-26T16:42:55.716Z", "status": 500, "error": "Internal Server Error", "path": "/books" }
The log says No property 'price' found for type 'Book'. In a real API, allow only known property names and return a 400 for the rest.
Common Mistakes
- Starting at page 1. Spring counts from 0. Asking for
page=1gives the second page, and your first page will look missing. - Returning `Page` directly. It works, but the JSON shape can change between versions. Wrap it in
PagedModelor your own DTO. - No maximum size. A client sending
size=1000000defeats the point of paging. Setmax-page-size. - Sorting a page after loading it. Sort in the database through
Pageable. If you sort a list in Java, only the current page gets sorted. - Unstable order. Sorting by a column that has many equal values, such as
author, can show the same book on two pages. Add a second sort, for exampleauthorand thentitle.
Interview Questions
What is the difference between Page and Slice?
Ans:A Page also knows the total number of records, so it runs an extra count query. A Slice only knows whether a next slice exists, so it is cheaper. Use Slice for "load more" lists.
How does Spring Boot build a Pageable from the URL?
Ans:A built-in argument resolver reads page, size and sort from the query string and creates a Pageable before your controller method runs.
Is page numbering zero-based or one-based?
Ans:Zero-based by default. You can change that by setting one-indexed-parameters to true in the same spring.data.web.pageable group.
Why can deep pages become slow?
Ans:Offset paging makes the database skip many rows before it returns the slice. On huge tables, keyset paging (filtering by the last seen id) is faster.
Key Points to Remember
- Return big lists in pages; never send every record.
Pageablecarries page, size and sort.Pagecarries the slice and the totals.- Pages start at 0, and
sort=field,descsets the direction. - Wrap a
PageinPagedModelfor a steady JSON shape. - Cap the page size and allow only known sort fields.
- Sorting belongs in the database, not in Java code after loading.
Frequently Asked Questions
Can I use pagination without a database?
Yes. The Pageable type comes from Spring Data Commons, so it works with any Spring Data store. For a plain list in memory, build a PageRequest yourself and cut the list with subList.
How do I sort by more than one field?
Repeat the parameter in the URL:
bashcurl "localhost:8080/books?sort=author,asc&sort=title,desc"
In code, chain the sorts:
javaSort sort = Sort.by("author").ascending() .and(Sort.by("title").descending());
What does the total count cost?
One extra COUNT query per request. If the table is huge and you do not need totals, return a Slice instead.
Can the page size be changed by the client?
Yes, through size, but always set a maximum. Spring Boot's max-page-size setting keeps clients honest.
Related Topics
- Building a Complete CRUD REST API: put pages on top of a full create, read, update and delete API.
- @RequestParam: see how query parameters such as page and size are read.
- JpaRepository: learn the repository methods behind findAll and derived queries.
- DTO Pattern: shape the records you send back in each page.
Practice Problems
Try each problem on your own first. Both use the web starter, the JPA starter and the H2 driver.
Easy: CinePoint Newest Movies First
CinePoint, a cinema chain, wants GET /movies to return four movies per page, newest release year first, with no parameters needed. Store seven movies in H2 and make the second page reachable with ?page=1.
Show answerHide answer
GET /movies already gives page 0 with four movies. The repository is just an empty JpaRepository.File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.cinepoint</groupId> <artifactId>movies</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: MoviesApplication.java in package com.cinepoint.movies
javapackage com.cinepoint.movies; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class MoviesApplication { public static void main(String[] args) { SpringApplication.run(MoviesApplication.class, args); } @Bean CommandLineRunner seed(MovieRepository movies) { return args -> { movies.save(new Movie("Monsoon Express", 2021)); movies.save(new Movie("The Silent Orbit", 2024)); movies.save(new Movie("Clay Dreams", 2019)); movies.save(new Movie("Last Show", 2023)); movies.save(new Movie("Golden Hour", 2022)); movies.save(new Movie("Kite Season", 2020)); movies.save(new Movie("Harbor Lights", 2025)); }; } }
File: Movie.java in package com.cinepoint.movies
javapackage com.cinepoint.movies; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; @Entity public class Movie { @Id @GeneratedValue private Long id; private String title; private int releaseYear; protected Movie() { } public Movie(String title, int releaseYear) { this.title = title; this.releaseYear = releaseYear; } public Long getId() { return id; } public String getTitle() { return title; } public int getReleaseYear() { return releaseYear; } }
File: MovieRepository.java in package com.cinepoint.movies
javapackage com.cinepoint.movies; import org.springframework.data.jpa.repository.JpaRepository; public interface MovieRepository extends JpaRepository<Movie, Long> { }
File: MovieController.java in package com.cinepoint.movies
javapackage com.cinepoint.movies; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PagedModel; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class MovieController { private final MovieRepository movies; public MovieController(MovieRepository movies) { this.movies = movies; } @GetMapping("/movies") public PagedModel<Movie> list( @PageableDefault(size = 4, sort = "releaseYear", direction = Sort.Direction.DESC) Pageable pageable) { return new PagedModel<>(movies.findAll(pageable)); } }
Call the second page:
bashcurl "localhost:8080/movies?page=1"
It prints the last three movies:
json{ "content": [ { "title": "Monsoon Express", "releaseYear": 2021, "id": 1 }, { "title": "Kite Season", "releaseYear": 2020, "id": 6 }, { "title": "Clay Dreams", "releaseYear": 2019, "id": 3 } ], "page": { "size": 4, "number": 1, "totalElements": 7, "totalPages": 2 } }
The JSON is spaced out here; curl prints it on one line.
Medium: Medico Pharmacy Search
A pharmacy app needs GET /medicines with these query parameters: keyword (part of the name, any case), page (default 0), size (default 3, never more than 10), sortBy (only name or priceInRupees) and direction (asc or desc). An unknown sortBy must return status 400.
Show answerHide answer
Set. Check the name first, clamp page and size into safe values, then build the PageRequest yourself.File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.medico</groupId> <artifactId>pharmacy</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: PharmacyApplication.java in package com.medico.pharmacy
javapackage com.medico.pharmacy; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class PharmacyApplication { public static void main(String[] args) { SpringApplication.run(PharmacyApplication.class, args); } @Bean CommandLineRunner seed(MedicineRepository medicines) { return args -> { medicines.save(new Medicine("Paracetamol 500", 18)); medicines.save(new Medicine("Paracetamol Syrup", 42)); medicines.save(new Medicine("Cough Syrup", 65)); medicines.save(new Medicine("Vitamin C Tablets", 90)); medicines.save(new Medicine("Antacid Syrup", 55)); medicines.save(new Medicine("Bandage Roll", 30)); medicines.save(new Medicine("Ibuprofen 400", 25)); }; } }
File: Medicine.java in package com.medico.pharmacy
javapackage com.medico.pharmacy; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; @Entity public class Medicine { @Id @GeneratedValue private Long id; private String name; private int priceInRupees; protected Medicine() { } public Medicine(String name, int priceInRupees) { this.name = name; this.priceInRupees = priceInRupees; } public Long getId() { return id; } public String getName() { return name; } public int getPriceInRupees() { return priceInRupees; } }
File: MedicineRepository.java in package com.medico.pharmacy
javapackage com.medico.pharmacy; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface MedicineRepository extends JpaRepository<Medicine, Long> { Page<Medicine> findByNameContainingIgnoreCase(String keyword, Pageable pageable); }
File: MedicineController.java in package com.medico.pharmacy
javapackage com.medico.pharmacy; import java.util.Set; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.web.PagedModel; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @RestController public class MedicineController { private static final Set<String> SORTABLE = Set.of("name", "priceInRupees"); private final MedicineRepository medicines; public MedicineController(MedicineRepository medicines) { this.medicines = medicines; } @GetMapping("/medicines") public PagedModel<Medicine> search( @RequestParam(defaultValue = "") String keyword, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "3") int size, @RequestParam(defaultValue = "name") String sortBy, @RequestParam(defaultValue = "asc") String direction) { if (!SORTABLE.contains(sortBy)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot sort by " + sortBy); } Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy); PageRequest request = PageRequest.of(Math.max(page, 0), Math.min(Math.max(size, 1), 10), sort); return new PagedModel<>(medicines.findByNameContainingIgnoreCase(keyword, request)); } }
Ask for the two dearest syrups:
bashcurl "localhost:8080/medicines?keyword=syrup&size=2&sortBy=priceInRupees&direction=desc"
The reply is below. Trying sortBy=id gives status 400.
Output:
json{ "content": [ { "name": "Cough Syrup", "priceInRupees": 65, "id": 3 }, { "name": "Antacid Syrup", "priceInRupees": 55, "id": 5 } ], "page": { "size": 2, "number": 0, "totalElements": 3, "totalPages": 2 } }
The JSON is spaced out here; curl prints it on one line.