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 Page and Slice;
  • receive page, size, sort parameters 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:

  • 0 is the page index. Spring Data counts pages from 0, not from 1.
  • 10 is 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);
}
  • findByStatus returns Page, which includes a count query.
  • findByCustomerName returns Slice. Section 4 will explain the difference.
  • findTop5ByStatus only needs sorting, not pagination. In that case, you pass Sort instead of Pageable.

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 id at the end. If you only sort by total and 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:

  • @PageableDefault sets defaults when the client doesn’t send page, size, or sort.
  • page.map(...) converts entities to DTOs while preserving pagination info. You should never return entities in your API.
  • PagedModel (from org.springframework.data.web, available since Spring Data 3.3 / Boot 3.3) wraps Page into 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 fetch with collections (@OneToMany) when paginating. Hibernate cannot apply limit at the SQL level for this case. It will load all data into memory first and then paginate, while also logging a warning HHH90003004: 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.

spring-bootspring-data-jpapageablerest-apijpa