Pagination et tri avec Pageable dans Spring Boot
1. Aperçu
Une API retournant la totalité de la table orders peut fonctionner correctement avec 100 lignes. Lorsque la table atteint un million de lignes, cette API fait s’écrouler l’application. Spring Data résout ce problème avec Pageable, un objet décrivant « quelle page chercher, combien d’éléments, comment les trier ».
Après la lecture de cet article, vous saurez comment :
- déclarer une méthode repository avec pagination ;
- choisir entre
PageetSlice; - recevoir les paramètres
page,size,sortdirectement depuis la requête HTTP ; - éviter quelques pièges de performance et de sécurité.
2. Préparation
Vous avez besoin de Spring Data JPA, Spring Web et d’une base de données. Cet article utilise H2 pour la simplicité :
<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>
L’entité exemple est une commande :
@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 }
La table est nommée orders car ORDER est un mot-clé SQL. Si laissée par défaut, de nombreuses bases de données retourneront une erreur de syntaxe.
3. Pagination au niveau du repository
3.1. Pageable et PageRequest
JpaRepository hérite déjà de PagingAndSortingRepository, vous disposez donc de findAll(Pageable). Pour créer un Pageable, utilisez 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("Nombre d'éléments cette page : " + page.getNumberOfElements());
System.out.println("Nombre total d'éléments : " + page.getTotalElements());
System.out.println("Nombre total de pages : " + page.getTotalPages());
System.out.println("Y a-t-il une page suivante ? " + page.hasNext());
Signification de chaque paramètre :
0est l’index de la page. Spring Data compte les pages à partir de 0, pas à partir de 1.10est le nombre d’éléments par page.Sort.by("createdAt").descending()trie les commandes avec les plus récentes en premier.
Avec 25 commandes dans la table, le résultat est :
Page : 0
Nombre d'éléments cette page : 10
Nombre total d'éléments : 25
Nombre total de pages : 3
Y a-t-il une page suivante ? true
Activez spring.jpa.show-sql=true et vous verrez que Hibernate exécute deux requêtes SQL :
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
La première requête récupère les données de la page. La deuxième compte le nombre total de lignes pour calculer totalElements et totalPages. La syntaxe offset ... fetch first est celle de H2. Avec MySQL ou PostgreSQL, vous verrez limit ? offset ?.
3.2. Derived query avec pagination
Il suffit d’ajouter le paramètre Pageable à la fin de la méthode. Spring Data gère le reste automatiquement :
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);
}
findByStatusretourne unePage, avec une requêtecount.findByCustomerNameretourne uneSlice. La partie 4 expliquera la différence.findTop5ByStatusa besoin que du tri sans pagination. Dans ce cas, vous passezSortau lieu dePageable.
3.3. Combiner plusieurs critères de tri
Sort permet de chaîner plusieurs colonnes :
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);
Le code précédent récupère la deuxième page, contenant les éléments 6 à 10, des commandes payées. Les commandes avec un montant total plus élevé sont classées en premier. Si deux commandes ont le même montant, celle la plus ancienne vient avant.
Attention : Toujours trier par au moins une colonne unique, par exemple ajouter
idà la fin. Si vous triez seulement partotalet que plusieurs lignes ont la même valeur, la base de données peut retourner un ordre différent entre les appels. En conséquence, un enregistrement peut apparaître sur deux pages, ou disparaître complètement.
4. Page ou Slice ?
Page<T> |
Slice<T> |
|
|---|---|---|
Requête count |
Oui | Non |
getTotalElements(), getTotalPages() |
Oui | Non |
hasNext() |
Oui | Oui |
| Adapté pour | Tableau avec numérotation (1, 2, 3 … 50) | Infinite scroll, bouton “Voir plus” |
Slice n’exécute pas de requête count. À la place, il récupère size + 1 lignes. S’il reçoit une ligne supplémentaire, il sait qu’il y a une page suivante :
Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true
Avec une table volumineuse, count(*) peut être aussi lent que la requête principale. Si votre interface n’a pas besoin d’afficher le nombre total de pages, utilisez Slice.
5. Recevoir Pageable depuis une requête HTTP
5.1. Contrôleur
Spring Boot configure automatiquement PageableHandlerMethodArgumentResolver. Vous pouvez donc déclarer Pageable comme paramètre du contrôleur :
@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());
}
}
Points importants :
@PageableDefaultdéfinit les valeurs par défaut lorsque le client ne soumet paspage,sizeousort.page.map(...)convertit les entités en DTO tout en préservant les informations de pagination. Vous ne devriez jamais exposer les entités directement dans votre API.PagedModel(du paquetorg.springframework.data.web, disponible depuis Spring Data 3.3 / Boot 3.3) encapsulePagedans une structure JSON stable.
Le client appelle l’API ainsi :
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 }
}
Le paramètre sort a le format propriété,direction. Vous pouvez le répéter plusieurs fois pour trier par plusieurs colonnes.
5.2. Pourquoi ne pas retourner directement Page ?
Retourner Page<OrderResponse> fonctionne quand même. Cependant, le JSON est alors le résultat de la sérialisation de PageImpl, une classe interne, donc la structure peut changer entre les versions. Depuis Spring Data 3.3, vous verrez un avertissement dans les logs en faisant cela. Il existe deux solutions : retourner PagedModel comme ci-dessus, ou activer la conversion automatique pour l’ensemble de l’application :
@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}
5.3. Configuration globale
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 est la propriété la plus importante. Si le client soumet size=1000000, Spring réduira automatiquement à 100. La limite par défaut est 2000, beaucoup trop élevée pour la plupart des API.
one-indexed-parameters=true permet au client d’envoyer page=1 pour la première page. Internement, Pageable utilise toujours des indices à partir de 0.
6. Pagination avec @Query
Avec vos propres requêtes JPQL, Spring Data ajoute quand même automatiquement order by et limit/offset. Il essaie aussi de déduire la requête count de votre requête. Lorsque votre requête contient join fetch, cette déduction est souvent incorrecte, vous devez donc déclarer une countQuery séparée :
@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);
Cette partie suppose que Order a une relation @ManyToOne vers une entité Customer. Avec @ManyToOne, join fetch fonctionne correctement avec la pagination.
Attention : N’utilisez pas
join fetchavec une collection (@OneToMany) lors de la pagination. Hibernate ne peut pas appliquerlimitau niveau SQL dans ce cas. Il charge toutes les données en mémoire puis découpe les pages, et affiche un avertissementHHH90003004: firstResult/maxResults specified with collection fetch; applying in memory. La bonne approche est de paginer d’abord sur l’ID, puis charger les collections selon cette liste d’ID.
7. Tests
@DataJpaTest démarre une base de données embarquée et charge uniquement la couche JPA, ce qui la rend idéale pour tester les 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("Client " + 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();
}
}
Dans le premier test, la page 1 (la deuxième page) contient les éléments 11 à 20. Le premier élément de la page est la 11e plus grande commande, soit 25 − 10 = 15, ce qui correspond à 150 000. Le deuxième test vérifie que la dernière page ne contient que 5 commandes.
8. Erreurs courantes
Trier par une propriété inexistante. La requête ?sort=passwordHash ou ?sort=abc provoque une PropertyReferenceException. Sans gestion, le client reçoit une erreur 500. Pire encore, le client peut trier par une colonne sensible pour exfiltrer des données. Vous devriez filtrer avec une 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 très profond. page=50000&size=20 force la base de données à lire et ignorer un million de lignes. Le temps de requête augmente linéairement avec l’offset. Avec des données volumineuses ou un infinite scroll, utilisez la pagination par keyset, c’est-à-dire « récupérer les lignes où id < lastSeenId ». Spring Data prend cela en charge via ScrollPosition et Window.
Confondre le numéro de page en commençant à 1. PageRequest.of(1, 10) est la deuxième page. Si votre frontend compte à partir de 1, activez one-indexed-parameters plutôt que de soustraire 1 un peu partout dans le code.
Les données changent entre les appels. La pagination par offset est instable lorsque de nouveaux enregistrements sont insérés. Un utilisateur peut voir une commande répétée en changeant de page. La pagination par keyset évite ce problème.
Quand NE PAS utiliser Page : lorsque la table est très volumineuse et votre interface n’a pas besoin du nombre total de pages, par exemple pour un flux ou un journal. La requête count est un surcoût inutile dans ce cas, utilisez Slice ou Window.
9. Conclusion
Pageable encapsule trois informations : page, taille et tri. Spring Data les transforme en limit/offset et order by à tous les niveaux, des derived queries, @Query aux paramètres du contrôleur. Vous utilisez Page lorsque vous avez besoin du nombre total de pages et Slice pour savoir s’il y a plus de données. Quand vous retournez du JSON, utilisez PagedModel pour une réponse stable.
N’oubliez pas trois points essentiels : limitez max-page-size, n’autorisez le tri que sur les propriétés d’une whitelist, et toujours triez par une colonne unique. Lorsque l’offset commence à ralentir, c’est le moment de passer à la pagination par keyset.