Pagination and Sorting with Pageable in Spring Boot
1. Overview
An API that returns the entire orders table can run fine with 100 rows. When the table reaches a million rows, that API crashes the application. Spring Data solves this with Pageable, an object describing “fetch which page, how many elements, sorted by what”.
After reading this article, you will know how to:
- declare repository methods with pagination;
- choose between
PageandSlice; - receive
page,size,sortparameters directly from HTTP requests; - avoid a few performance and security pitfalls.
2. Setup
You need Spring Data JPA, Spring Web, and a database. This article uses H2 for simplicity:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
The sample entity is an order:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerName;
private BigDecimal total;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime createdAt;
protected Order() {
}
public Order(String customerName, BigDecimal total, OrderStatus status, LocalDateTime createdAt) {
this.customerName = customerName;
this.total = total;
this.status = status;
this.createdAt = createdAt;
}
// getters
}
public enum OrderStatus { NEW, PAID, SHIPPED }
The table is named orders because ORDER is an SQL keyword. If left at the default, many databases will report a syntax error.
3. Pagination at the repository layer
3.1. Pageable and PageRequest
JpaRepository already inherits PagingAndSortingRepository, so you have findAll(Pageable) available. To create a Pageable, you use PageRequest.of:
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(pageable);
System.out.println("Page: " + page.getNumber());
System.out.println("Number of elements on this page: " + page.getNumberOfElements());
System.out.println("Total elements: " + page.getTotalElements());
System.out.println("Total pages: " + page.getTotalPages());
System.out.println("Has next page? " + page.hasNext());
The meaning of each parameter:
0is the page index. Spring Data counts pages from 0, not from 1.10is the number of elements per page.Sort.by("createdAt").descending()sorts orders with the newest first.
With 25 orders in the table, the output is:
Page: 0
Number of elements on this page: 10
Total elements: 25
Total pages: 3
Has next page? true
Enable spring.jpa.show-sql=true and you’ll see Hibernate run two SQL statements:
select o.id, o.created_at, o.customer_name, o.status, o.total
from orders o order by o.created_at desc offset ? rows fetch first ? rows only
select count(o.id) from orders o
The first statement fetches the page data. The second counts the total rows to calculate totalElements and totalPages. The offset ... fetch first syntax is H2-specific. With MySQL or PostgreSQL, you’ll see limit ? offset ?.
3.2. Derived queries with pagination
You just need to add a Pageable parameter at the end of the method. Spring Data handles the rest automatically:
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
Slice<Order> findByCustomerName(String customerName, Pageable pageable);
List<Order> findTop5ByStatus(OrderStatus status, Sort sort);
}
findByStatusreturnsPage, which includes acountquery.findByCustomerNamereturnsSlice. Section 4 will explain the difference.findTop5ByStatusonly needs sorting, not pagination. In that case, you passSortinstead ofPageable.
3.3. Combining multiple sort criteria
Sort lets you chain multiple columns:
Sort sort = Sort.by(Sort.Order.desc("total"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(1, 5, sort);
Page<Order> paidOrders = orderRepository.findByStatus(OrderStatus.PAID, pageable);
The code above fetches page two, containing elements 6 to 10, of all paid orders. Orders with higher totals are ranked first. If two orders have the same total, the older one appears first.
Note: Always sort by at least one unique column, for example add
idat the end. If you only sort bytotaland many rows have the same value, the database is allowed to return them in different orders across calls. This can result in a record appearing on both pages, or disappearing from both.
4. Page or Slice?
Page<T> |
Slice<T> |
|
|---|---|---|
count query |
Yes | No |
getTotalElements(), getTotalPages() |
Yes | No |
hasNext() |
Yes | Yes |
| Best for | Tables with numbered pages (1, 2, 3 … 50) | Infinite scroll, “Load more” button |
Slice does not run a count query. Instead, it fetches size + 1 rows. If it receives an extra row, it knows there’s a next page:
Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true
With large tables, count(*) can be as slow as the main query. If your UI doesn’t need to display the total page count, use Slice.
5. Receiving Pageable from HTTP requests
5.1. Controller
Spring Boot auto-configures PageableHandlerMethodArgumentResolver. So you can declare Pageable as a controller parameter:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderRepository orderRepository;
public OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@GetMapping
public PagedModel<OrderResponse> list(
@RequestParam OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<OrderResponse> page = orderRepository.findByStatus(status, pageable)
.map(OrderResponse::from);
return new PagedModel<>(page);
}
}
public record OrderResponse(Long id, String customerName, BigDecimal total, LocalDateTime createdAt) {
static OrderResponse from(Order o) {
return new OrderResponse(o.getId(), o.getCustomerName(), o.getTotal(), o.getCreatedAt());
}
}
Key points to note:
@PageableDefaultsets defaults when the client doesn’t sendpage,size, orsort.page.map(...)converts entities to DTOs while preserving pagination info. You should never return entities in your API.PagedModel(fromorg.springframework.data.web, available since Spring Data 3.3 / Boot 3.3) wrapsPageinto a stable JSON structure.
A client would call the API like this:
curl "http://localhost:8080/orders?status=PAID&page=0&size=2&sort=total,desc&sort=id,asc"
{
"content": [
{ "id": 17, "customerName": "Bình", "total": 990000.00, "createdAt": "2026-10-01T09:15:00" },
{ "id": 4, "customerName": "An", "total": 870000.00, "createdAt": "2026-09-28T14:02:00" }
],
"page": { "size": 2, "number": 0, "totalElements": 12, "totalPages": 6 }
}
The sort parameter has the format property,direction. You can repeat it multiple times to sort by multiple columns.
5.2. Why not return Page directly?
Returning Page<OrderResponse> still works. However, the JSON from serializing PageImpl, an internal class, means the structure can change between versions. Starting with Spring Data 3.3, you’ll see a warning in the logs when doing this. There are two solutions: return PagedModel as above, or enable automatic conversion globally:
@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}
5.3. Global configuration
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
spring.data.web.pageable.page-parameter=page
spring.data.web.pageable.size-parameter=size
spring.data.web.sort.sort-parameter=sort
max-page-size is the most important property. If a client sends size=1000000, Spring will automatically cap it at 100. The default limit is 2000, which is too large for most APIs.
one-indexed-parameters=true lets the client send page=1 for the first page. Internally, Pageable still uses zero-based indexing.
6. Pagination with @Query
With custom JPQL, Spring Data still automatically adds order by and limit/offset. It also tries to infer the count query from your main query. When your query has join fetch, this inference often goes wrong, so you need to declare countQuery separately:
@Query(value = "select o from Order o join fetch o.customer c where c.city = :city",
countQuery = "select count(o) from Order o join o.customer c where c.city = :city")
Page<Order> findByCity(@Param("city") String city, Pageable pageable);
This example assumes Order has a @ManyToOne relationship to a Customer entity. With @ManyToOne, join fetch combined with pagination works fine.
Note: Don’t use
join fetchwith collections (@OneToMany) when paginating. Hibernate cannot applylimitat the SQL level for this case. It will load all data into memory first and then paginate, while also logging a warningHHH90003004: firstResult/maxResults specified with collection fetch; applying in memory. The correct approach is to paginate by ID first, then fetch the collections by that list of IDs.
7. Testing
@DataJpaTest starts an embedded database and only loads the JPA layer, making it perfect for testing repositories:
@DataJpaTest
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@BeforeEach
void setUp() {
LocalDateTime base = LocalDateTime.of(2026, 10, 1, 8, 0);
for (int i = 1; i <= 25; i++) {
orderRepository.save(new Order("Customer " + i, BigDecimal.valueOf(i * 10_000L),
OrderStatus.PAID, base.plusHours(i)));
}
}
@Test
void shouldReturnSecondPageSortedByTotalDesc() {
Pageable pageable = PageRequest.of(1, 10, Sort.by("total").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);
assertThat(page.getTotalElements()).isEqualTo(25);
assertThat(page.getTotalPages()).isEqualTo(3);
assertThat(page.getContent()).hasSize(10);
assertThat(page.getContent().getFirst().getTotal())
.isEqualByComparingTo("150000");
assertThat(page.hasNext()).isTrue();
}
@Test
void lastPageShouldContainRemainingOrders() {
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, PageRequest.of(2, 10));
assertThat(page.getContent()).hasSize(5);
assertThat(page.isLast()).isTrue();
}
}
In the first test, page 1 (the second page) contains elements 11 to 20. The first element of the page is the 11th largest order, which is 25 − 10 = 15, corresponding to 150,000. The second test verifies that the last page contains only 5 orders.
8. Common pitfalls
Sorting by a non-existent property. A request like ?sort=passwordHash or ?sort=abc causes a PropertyReferenceException. Without handling, the client gets a 500 error. Worse, clients could sort by a sensitive column to snoop on data. You should filter via whitelist:
private static final Set<String> SORTABLE = Set.of("createdAt", "total", "id");
static Pageable sanitize(Pageable pageable) {
Sort safe = Sort.by(pageable.getSort().stream()
.filter(order -> SORTABLE.contains(order.getProperty()))
.toList());
return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
safe.isSorted() ? safe : Sort.by("createdAt").descending());
}
Offset too deep. page=50000&size=20 forces the database to read and skip a million rows. Query time increases linearly with offset. With large datasets or infinite scroll, use keyset pagination, i.e., “fetch rows where id < lastSeenId”. Spring Data supports this via ScrollPosition and Window.
Confusing pages starting from 1. PageRequest.of(1, 10) is the second page. If your frontend counts from 1, enable one-indexed-parameters instead of scattering −1 throughout the code.
Data changes between calls. Offset-based pagination is unstable when new records are inserted. Users might see an order repeated when moving to the next page. Keyset pagination avoids this problem.
When NOT to use Page: when the table is huge and the UI doesn’t need the total page count, like a feed or log. The count query is wasted overhead in that case, so use Slice or Window instead.
9. Conclusion
Pageable bundles three pieces of info into one object: page, size, and sort order. Spring Data translates it to limit/offset and order by at every layer, from derived queries, @Query, to controller parameters. Use Page when you need the total page count and Slice when you only need to know if there’s more data. When returning JSON, use PagedModel to keep the response structure stable.
Don’t forget three things: cap max-page-size, allow sorting only by whitelisted properties, and always sort by at least one unique column. When offset queries get slow, that’s when you should switch to keyset pagination.