For developers facing performance issues with REST APIs that return large amounts of data, this article explains pagination implementation using Spring Data JPA’s Pageable and Page — from the basics to practical usage. By the end, you’ll be able to implement an endpoint that accepts page number, size, and sort conditions as query parameters and returns pagination metadata in a proper JSON format.
This article assumes Spring Boot 3.x. If you are using Spring Boot 2.x, some configuration paths may differ.
What Is Pagination — Why REST APIs Need It
Returning all records at once from a REST API causes the following problems:
- Increased memory consumption — Loading thousands or tens of thousands of records into memory at once puts pressure on server memory
- Slow response times — Serializing large amounts of data and transferring it over the network takes time
- Wasted network bandwidth — Data the client doesn’t need gets transferred anyway
Pagination solves these problems by fetching data in fixed-size chunks. Spring Data JPA provides a mechanism to easily implement pagination using the Pageable interface and the Page interface.
Basic Usage of Pageable
With Spring Data JPA, simply accepting Pageable as a controller method argument automatically reads pagination information from query parameters.
@RestController
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@GetMapping("/api/products")
public Page<Product> getProducts(Pageable pageable) {
return productRepository.findAll(pageable);
}
}
This endpoint can be accessed with query parameters such as ?page=0&size=10. Sort conditions can also be specified in the format &sort=name,asc.
Important: Page numbers in Spring Data JPA are 0-based. The first page is page=0.
Setting Default Values with the @PageableDefault Annotation
You can set default values for when no query parameters are provided.
@GetMapping("/api/products")
public Page<Product> getProducts(
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
return productRepository.findAll(pageable);
}
With this configuration, accessing the endpoint without parameters returns 20 items per page, sorted by creation date in descending order.
Implementing Page<T> Returns in a Repository
In a repository interface that extends JpaRepository, simply adding Pageable as an argument to a custom query method and setting the return type to Page<T> enables pagination support.
public interface ProductRepository extends JpaRepository<Product, Long> {
// Filter by category (pagination-enabled)
Page<Product> findByCategory(String category, Pageable pageable);
// Filter by price range (pagination-enabled)
Page<Product> findByPriceBetween(Integer minPrice, Integer maxPrice, Pageable pageable);
}
The findAll(Pageable pageable) method provided by JpaRepository is available through inheritance — there is no need to redefine it in your repository.
The Page<T> interface provides methods to access pagination metadata: getContent() for the data list, getTotalElements() for the total record count, getTotalPages() for the total number of pages, and more.
Complete Code Example — Entity, Repository, Controller
Let’s implement a fully working pagination API.
// Entity
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String category;
private Integer price;
private LocalDateTime createdAt;
// getters and setters omitted
}
// Repository
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Page<Product> findByCategory(String category, Pageable pageable);
}
// Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@GetMapping
public Page<Product> getProducts(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
return productRepository.findAll(pageable);
}
}
Example Response
{
"content": [
{
"id": 1,
"name": "Product A",
"category": "Electronics",
"price": 10000,
"createdAt": "2026-01-15T10:00:00"
},
{
"id": 2,
"name": "Product B",
"category": "Furniture",
"price": 25000,
"createdAt": "2026-01-15T15:30:00"
}
],
"pageable": {
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"pageNumber": 0,
"pageSize": 10,
"offset": 0,
"paged": true,
"unpaged": false
},
"totalPages": 5,
"totalElements": 50,
"last": false,
"first": true,
"size": 10,
"number": 0,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"numberOfElements": 10,
"empty": false
}
Specifying Sort Conditions
Multiple sort conditions can be specified via query parameters. When multiple sort parameters are provided — such as ?sort=category,asc&sort=price,desc — they are applied in order from left to right.
If a non-existent field name is specified, Spring Data JPA throws a PropertyReferenceException. In production, it is recommended to handle this exception appropriately and return a clear error message to the client.
To configure multiple default sort conditions, you can pass an array to the sort parameter of @PageableDefault.
Customizing the Response Format — Converting Page to DTO
The default Page response often contains more information than necessary. Let’s create a custom response DTO that returns only what’s needed.
// ProductDTO
@Getter
public class ProductDTO {
private final Long id;
private final String name;
private final String category;
private final Integer price;
public ProductDTO(Product product) {
this.id = product.getId();
this.name = product.getName();
this.category = product.getCategory();
this.price = product.getPrice();
}
}
// PageResponse
@Getter
public class PageResponse<T> {
private final List<T> content;
private final long totalElements;
private final int totalPages;
private final int currentPage;
private final int pageSize;
private final boolean hasNext;
private final boolean hasPrevious;
public PageResponse(org.springframework.data.domain.Page<T> page) {
this.content = page.getContent();
this.totalElements = page.getTotalElements();
this.totalPages = page.getTotalPages();
this.currentPage = page.getNumber();
this.pageSize = page.getSize();
this.hasNext = page.hasNext();
this.hasPrevious = page.hasPrevious();
}
}
Converting Page<Entity> to Page<DTO>
The map() method on the Page interface makes this conversion straightforward.
@GetMapping
public PageResponse<ProductDTO> getProducts(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
Page<Product> productPage = productRepository.findAll(pageable);
Page<ProductDTO> dtoPage = productPage.map(ProductDTO::new);
return new PageResponse<>(dtoPage);
}
This produces a clean, simple response like the following:
{
"content": [
{
"id": 1,
"name": "Product A",
"category": "Electronics",
"price": 10000
}
],
"totalElements": 50,
"totalPages": 5,
"currentPage": 0,
"pageSize": 10,
"hasNext": true,
"hasPrevious": false
}
Error Handling for Out-of-Range Page Numbers
When a page number exceeds the total number of pages, Spring Data JPA returns an empty list. If you want to treat this as an error, you need to check explicitly.
@GetMapping
public PageResponse<ProductDTO> getProducts(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
Page<Product> productPage = productRepository.findAll(pageable);
// Throw an exception if the page number is out of range
if (productPage.getTotalElements() > 0 &&
pageable.getPageNumber() >= productPage.getTotalPages()) {
throw new PageOutOfBoundsException(
String.format("Page number %d is out of range. Total pages: %d",
pageable.getPageNumber(), productPage.getTotalPages())
);
}
Page<ProductDTO> dtoPage = productPage.map(ProductDTO::new);
return new PageResponse<>(dtoPage);
}
// Custom exception class
public class PageOutOfBoundsException extends RuntimeException {
public PageOutOfBoundsException(String message) {
super(message);
}
}
// Exception handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PageOutOfBoundsException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handlePageOutOfBounds(PageOutOfBoundsException ex) {
return new ErrorResponse("INVALID_PAGE", ex.getMessage());
}
@Getter
@AllArgsConstructor
public static class ErrorResponse {
private String code;
private String message;
}
}
For a unified error response approach, see Returning Unified Error Responses from a Spring Boot REST API for a detailed walkthrough.
Best Practices for Pagination Implementation
Global Configuration via application.yml
spring:
data:
web:
pageable:
default-page-size: 20 # Default page size
max-page-size: 100 # Maximum page size
one-indexed-parameters: false # Use 1-based page numbers (default is false)
Note for Spring Boot 3.x: The configuration paths above are valid for Spring Boot 3.x and later. If you are using Spring Boot 2.x, the paths may differ.
Setting max-page-size automatically caps the size even if a client requests an excessively large value. For example, with max-page-size: 100, a request of ?size=1000 will still return at most 100 records.
Pagination Considerations for Large Datasets
When accessing a large offset (e.g., page 10,000 or beyond) on datasets with millions of rows, database performance degrades significantly because it must skip a large number of rows. For example, accessing page 10,000 (offset=100,000) on 10 million records requires the database to skip the first 100,000 rows.
For large-scale data like this, consider adopting cursor-based pagination (keyset pagination, e.g., WHERE id > last_id LIMIT 20). While not covered in this article, it is a strong alternative when offset-based pagination becomes impractical at scale.
Summary
This article covered REST API pagination implementation using Spring Data JPA’s Pageable and Page.
Simply accepting Pageable as a controller argument automatically reads pagination info from query parameters, and @PageableDefault lets you set default values. To convert Page<Entity> to Page<DTO>, the map() method makes it easy.
Pagination is an essential feature for APIs that handle large amounts of data. In production, it is recommended to cap the maximum page size via application.yml and implement appropriate error handling.
For related implementations, also check out Spring Data JPA Entity Relationship Mapping and Returning Unified Error Responses from a Spring Boot REST API.