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 Page et Slice ;
  • recevoir les paramètres page, size, sort directement 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 :

  • 0 est l’index de la page. Spring Data compte les pages à partir de 0, pas à partir de 1.
  • 10 est 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);
}
  • findByStatus retourne une Page, avec une requête count.
  • findByCustomerName retourne une Slice. La partie 4 expliquera la différence.
  • findTop5ByStatus a besoin que du tri sans pagination. Dans ce cas, vous passez Sort au lieu de Pageable.

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 par total et 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 :

  • @PageableDefault définit les valeurs par défaut lorsque le client ne soumet pas page, size ou sort.
  • 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 paquet org.springframework.data.web, disponible depuis Spring Data 3.3 / Boot 3.3) encapsule Page dans 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 fetch avec une collection (@OneToMany) lors de la pagination. Hibernate ne peut pas appliquer limit au niveau SQL dans ce cas. Il charge toutes les données en mémoire puis découpe les pages, et affiche un avertissement HHH90003004: 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.

spring-bootspring-data-jpapageablerest-apijpa