DEV Community

Cover image for Pagination API : Curseur vs Offset, comment choisir ?
Antoine Laurent
Antoine Laurent

Posted on Originally published at apidog.com

Pagination API : Curseur vs Offset, comment choisir ?

Pagination d’API : offset ou curseur ?

Chaque point de terminaison de liste finit par faire face à la même question : comment diviser 2 millions de commandes en pages qu’un client peut parcourir ? La pagination par offset fournit un SQL simple et des numéros de page compréhensibles. La pagination par curseur garantit des résultats stables et une latence constante, mais ne permet pas d’aller directement à la page 47.

Essayez Apidog dès aujourd’hui

La plupart des équipes choisissent l’offset parce qu’il est utilisé dans la majorité des tutoriels. Puis la table atteint quelques millions de lignes, la page 4 000 expire et les utilisateurs voient deux fois le même enregistrement.

Ce guide compare les deux approches, explique les limites de l’offset, présente les choix de Stripe et Slack, puis montre comment tester chaque style avec des requêtes chaînées dans Apidog. Pour une vue d’ensemble, consultez notre guide de pagination d’API.

Pagination par offset

La pagination par offset correspond directement au SQL. Le client envoie un numéro de page et une taille de page ; le serveur les traduit en LIMIT et OFFSET.

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Enter fullscreen mode Exit fullscreen mode

Cette requête renvoie la page 3, avec 25 lignes par page :

GET /v1/orders?page=3&per_page=25
Enter fullscreen mode Exit fullscreen mode

Réponse typique :

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}
Enter fullscreen mode Exit fullscreen mode

L’offset est facile à construire, permet d’accéder à n’importe quelle page et facilite l’affichage d’un total. Pour une petite table d’administration, c’est souvent le bon choix. Ce guide étape par étape sur la pagination dans les API REST détaille une implémentation complète.

En production, deux problèmes structurels apparaissent.

1. La dérive des pages

L’offset compte les lignes depuis le début du résultat trié. Il ne sait pas quelles lignes ont déjà été vues.

Supposons qu’un utilisateur charge les lignes 1 à 25, triées par date décroissante. Trois commandes arrivent avant qu’il demande la page 2 (OFFSET 25). Les anciennes lignes 23, 24 et 25 passent aux positions 26, 27 et 28 : elles apparaissent de nouveau.

Les suppressions produisent l’effet inverse. Si trois lignes disparaissent de la page 1, OFFSET 25 ignore désormais trois lignes que l’utilisateur n’a jamais vues. Des données sont perdues silencieusement.

Cette dérive est acceptable pour un rapport mensuel statique. Elle ne l’est pas pour :

  • un flux d’activité ;
  • un point de terminaison de synchronisation ;
  • un script qui parcourt les pages pendant que des écritures continuent.

2. Le coût des offsets profonds

OFFSET 500000 ne téléporte pas la base de données à la ligne 500 001. PostgreSQL parcourt les 500 000 entrées précédentes, les ignore, puis renvoie les 25 suivantes. Le coût augmente donc linéairement : O(n), où n est l’offset.

Sur une table PostgreSQL de 2 millions de lignes indexée sur created_at :

  • LIMIT 25 OFFSET 0 lit 25 entrées d’index : quelques millisecondes ;
  • LIMIT 25 OFFSET 100000 lit 100 025 entrées et en rejette 100 000 : des dizaines de millisecondes ;
  • LIMIT 25 OFFSET 1500000 lit 1,5 million d’entrées : des centaines de millisecondes, davantage de tampons et plus de CPU.

L’article no-offset de Markus Winand montre ce coût avec des plans d’exécution. En production, les journaux de requêtes lentes sont souvent dominés par les offsets élevés, notamment lorsqu’un robot parcourt chaque page d’une API publique. Un seul client peut suffire à faire doubler votre p99.

Pagination par curseur

La pagination par curseur, aussi appelée pagination par jeu de clés (keyset pagination), remplace le compteur de lignes par une position de départ :

« Donnez-moi les lignes qui suivent cet enregistrement. »

Le SQL utilise une comparaison sur la clé de tri :

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Enter fullscreen mode Exit fullscreen mode

La comparaison porte sur deux colonnes. created_at seul n’est pas unique : deux commandes peuvent avoir le même horodatage à la milliseconde près. Sans clé de départage, des lignes peuvent être ignorées ou répétées à la limite entre deux pages.

L’ajout de id rend le tri total et la pagination exacte. Avec un index composite sur (created_at, id), la base se positionne directement à la limite et lit 25 entrées. La première page et la page 60 000 ont alors un coût similaire.

Utiliser un curseur opaque

N’exposez pas les valeurs brutes de la clé de tri. Encodez-les dans un jeton opaque, généralement en base64 :

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Enter fullscreen mode Exit fullscreen mode

L’opacité est une décision de conception, pas seulement une mesure d’obfuscation. Si les clients ne peuvent pas analyser le curseur, vous pouvez modifier la clé de tri, ajouter une information de shard ou changer de moteur de stockage sans casser le contrat.

Le compromis est clair : il n’y a pas de page 47. Le client avance une page à la fois, éventuellement en arrière si vous émettez aussi un curseur précédent. Les totaux nécessitent une requête distincte.

Pour les ensembles très volumineux, consultez ce guide sur la conception de la pagination d’API pour des millions d’enregistrements.

Offset ou curseur : comparaison rapide

Dimension Pagination par offset Pagination par curseur
Accès à une page arbitraire Oui Non, parcours séquentiel
Total et nombre de pages Faciles à inclure Requête de comptage séparée
Performance sur une page profonde O(n), se dégrade avec la profondeur O(1) par page
Stabilité pendant les écritures Risque de doublons et de lacunes Stable, ancrée à une ligne
Coût de construction Faible Modéré : encodage, départage, index
Contraintes de tri Tout ORDER BY Clé unique et indexée
Mise en cache des URL Simple, URL prévisibles Plus difficile, curseurs variables
Complexité côté client Faible Faible avec une bonne enveloppe

La contrainte la plus importante est le tri déterministe. Si le point de terminaison autorise un tri sur une colonne mutable et non unique comme status, la pagination par jeu de clés devient difficile. L’offset tolère un ordre approximatif ; le curseur l’exige exact.

Quel style choisir ?

Adaptez la stratégie à la façon dont les données sont consommées.

Tables d’administration et tableaux de bord : offset

Utilisez l’offset pour les outils internes contenant quelques milliers de lignes, lorsque les utilisateurs cliquent sur des numéros de page et ont besoin d’un total comme « 1 848 résultats ».

La dérive est généralement négligeable, la profondeur reste faible et l’accès direct aux pages apporte une vraie valeur.

Flux à défilement infini : curseur

Un utilisateur ne cherche presque jamais la page 47 d’un flux. Il demande « plus », tandis que les écritures continuent. Les doublons sont immédiatement visibles et nuisent à l’expérience.

API publiques : curseur

Vous ne contrôlez pas vos consommateurs. L’un d’eux finira par parcourir toutes les pages. Avec l’offset, les pages profondes deviennent votre problème ; avec un curseur opaque, chaque page reste peu coûteuse et vous pouvez faire évoluer l’implémentation interne.

Ce guide de pagination d’API REST couvre les conventions d’URL et d’en-tête.

Exportations et synchronisations : curseur

Une tâche qui extrait 2 millions de commandes a besoin de deux garanties :

  1. ne manquer aucune ligne malgré les écritures concurrentes ;
  2. conserver un coût stable par page.

L’offset ne garantit ni l’une ni l’autre. Un curseur fournit également un point de reprise naturel si la tâche s’arrête après 1,4 million de lignes.

Règle générale : choisissez l’offset pour les interfaces petites, consultées par des humains et riches en totaux ; choisissez le curseur pour tout ce qui est volumineux, dynamique ou public.

Comment les API réelles procèdent-elles ?

Stripe

Stripe utilise principalement la pagination par curseur. Ses points de terminaison de liste acceptent starting_after — l’identifiant d’un objet — et limit. Les réponses incluent has_more.

Pour récupérer la page suivante des prélèvements, transmettez l’ID du dernier prélèvement reçu. La documentation de pagination de Stripe illustre ce modèle. Elle ne renvoie pas de nombre total, une omission délibérée compte tenu de son volume d’écritures.

GitHub

L’API REST de GitHub expose encore page et per_page sur de nombreux points de terminaison, avec des en-têtes Link vers les pages suivante et précédente.

La documentation de pagination de GitHub recommande de suivre l’en-tête Link plutôt que de construire les URL manuellement. Certains points de terminaison plus récents utilisent des curseurs, notamment pour éviter les parcours profonds sur de très grands dépôts.

Slack

Slack a migré son API Web vers la pagination par curseur et recommande cette approche pour les nouvelles méthodes. Des méthodes comme conversations.history renvoient response_metadata.next_cursor. Une chaîne vide indique la fin du parcours, comme expliqué dans la documentation de pagination de Slack.

Trois API à fort trafic suivent donc la même tendance : privilégier les curseurs.

Concevoir l’enveloppe de réponse

Une API à curseur doit avoir une enveloppe simple et prévisible :

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Enter fullscreen mode Exit fullscreen mode

Appliquez ces quatre règles :

  1. Renvoyez toujours has_more. Une page courte ne signifie pas forcément que le parcours est terminé, notamment si un filtrage intervient après la récupération.
  2. Renvoyez next_cursor: null sur la dernière page et documentez cette convention. Une chaîne vide, comme chez Slack, convient également ; ne mélangez jamais les deux.
  3. Rejetez les curseurs invalides avec HTTP 400, et renvoyez un code d’erreur lisible par machine. Une erreur de curseur masquée par une réponse 200 complique fortement le débogage.
  4. Signez ou versionnez la charge utile si le curseur encode autre chose que les clés de tri. Cela simplifiera les futures migrations de schéma.

Tester la pagination dans Apidog

Les bugs apparaissent aux limites : dernière page, page vide ou curseur dont la ligne d’ancrage a été supprimée. Un clic manuel ne suffit pas ; utilisez un scénario de test chaîné.

Tester un point de terminaison à curseur

  1. Appelez le point de terminaison et extrayez $.next_cursor avec un post-processeur. Stockez la valeur dans une variable telle que nextCursor. Apidog permet de copier directement le JSONPath depuis le panneau de réponse ; consultez ce guide sur les assertions et l’extraction de variables avec JSONPath.
  2. Placez la requête suivante dans une étape ForEach ou une boucle.
  3. Transmettez {{nextCursor}} comme paramètre cursor.
  4. Réextrayez $.next_cursor à chaque itération.
  5. Arrêtez-vous lorsque has_more vaut false.
  6. Vérifiez à chaque passage qu’aucun id n’est répété et que la taille de page ne dépasse jamais limit.

Tester un point de terminaison à offset

Utilisez une variable page :

  • incrémentez page à chaque requête ;
  • vérifiez que data.length vaut per_page jusqu’à la dernière page ;
  • vérifiez que total reste cohérent pendant tout le parcours.

Ajouter les cas limites

Traitez chaque cas comme une étape distincte avec des assertions explicites :

  • Page vide : appliquez un filtre qui ne correspond à aucune ligne ; vérifiez data: [], has_more: false et le statut 200.
  • Curseur invalide : envoyez cursor=not-a-real-cursor ; vérifiez le statut 400 et la présence d’un code d’erreur lisible par machine.
  • Ligne d’ancrage supprimée : créez une commande, obtenez un curseur ancré dessus, supprimez la commande, puis réutilisez le curseur. Vérifiez que le parcours reprend à la position correcte au lieu d’échouer.

Les comparaisons par jeu de clés gèrent naturellement la suppression de la ligne d’ancrage. Le test permet de le confirmer avant qu’un consommateur ne découvre le problème.

Après validation en local, exécutez le scénario en CI à chaque fusion. Téléchargez Apidog gratuitement pour construire un scénario complet avec parcours par curseur, boucles et assertions.

FAQ

La pagination par curseur est-elle toujours meilleure ?

Non. L’offset convient mieux lorsque les utilisateurs ont besoin de numéros de page, de totaux et d’un accès aléatoire sur un ensemble de données modeste — ce qui est fréquent dans les outils d’administration internes.

Les curseurs sont préférables lorsque les données sont volumineuses, les écritures fréquentes ou l’API publique. L’erreur classique consiste à choisir l’offset par défaut pour une API publique, puis à découvrir son coût O(n) après le lancement.

Comment obtenir un total avec un curseur ?

Exécutez un SELECT COUNT(*) séparé avec les mêmes filtres, soit via un point de terminaison distinct, soit avec un paramètre optionnel comme include_count=true.

Mettez ce résultat en cache agressivement. Un nombre approximatif actualisé chaque minute suffit à la plupart des interfaces. Stripe ignore complètement les totaux, ce qui montre qu’ils ne sont pas toujours nécessaires.

Puis-je proposer les deux styles sur le même point de terminaison ?

C’est possible, et GitHub l’a fait pendant sa transition, mais évitez cette approche pour une nouvelle API. Deux styles impliquent deux ensembles de cas limites, deux matrices de tests et davantage de confusion côté client.

Choisissez une stratégie par point de terminaison. Si vous concevez le contrat depuis zéro, utilisez des conventions cohérentes pour les noms de paramètres, comme celles présentées dans ce guide de pagination d’API REST.

Que se passe-t-il si la ligne d’ancrage est supprimée ?

Avec la pagination par jeu de clés, rien ne casse. La condition suivante n’exige pas que la ligne d’ancrage existe :

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

Elle cherche simplement la position limite et continue le parcours. C’est un avantage important par rapport aux curseurs conçus comme une recherche directe de ligne — et un cas limite à vérifier dans votre scénario de test Apidog.

Top comments (0)