Pagination API : Curseur vs Offset, comment choisir ?

Pagination par curseur vs pagination par décalage : dérive de page, coût des décalages profonds, SQL de jeux de clés, exemples de Stripe et de Slack, et comment tester les deux dans Apidog.

INEZA Felin-Michel

INEZA Felin-Michel

31 August 2026

Pagination API : Curseur vs Offset, comment choisir ?

Apidog pour les entreprises

Déploiement sur site

SSO & RBAC

Conforme SOC 2

Découvrir Apidog Enterprise

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 ? Choisissez la pagination par offset et vous obtiendrez un SQL simple ainsi que des numéros de page que les utilisateurs comprennent. Choisissez la pagination par curseur et vous obtiendrez des résultats stables ainsi qu'une latence constante à n'importe quelle profondeur, mais vous renoncerez à la fonction « aller à la page 47 ».

La plupart des équipes choisissent l'offset car c'est la valeur par défaut dans tous les tutoriels. Ensuite, la table des commandes atteint quelques millions de lignes, la page 4 000 commence à expirer, et les utilisateurs signalent voir le même enregistrement deux fois en faisant défiler. Ce guide explique comment fonctionnent les deux styles, où l'offset pose problème, pourquoi Stripe et Slack utilisent des curseurs, et comment tester les deux styles avec des requêtes chaînées dans Apidog. À la fin, vous saurez exactement lequel convient à votre point de terminaison.

Si vous souhaitez d'abord une vue d'ensemble, notre guide de pagination d'API couvre toutes les stratégies côte à côte. Cet article approfondit les deux qui comptent le plus.

Comment fonctionne la 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;

Cette requête renvoie la page 3 de votre liste de commandes, avec 25 lignes par page. La requête ressemble à ceci :

GET /v1/orders?page=3&per_page=25

Et une 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
}

L'attrait est évident. Les clients peuvent sauter à n'importe quelle page. Le serveur peut renvoyer un nombre total. N'importe quel développeur peut le construire en un après-midi. Pour une petite table d'administration, c'est la bonne solution, et notre guide étape par étape sur la pagination dans les API REST détaille une implémentation complète par offset.

Mais l'offset comporte deux problèmes structurels, et aucun des deux n'apparaît en développement. Les deux apparaissent en production.

Problème 1 : dérive de page

L'offset compte les lignes à partir du début du résultat trié. Il ne sait rien des lignes que le client a déjà vues. Ainsi, lorsque des lignes sont insérées ou supprimées entre les requêtes, les pages se décalent sous le client.

Supposons qu'un utilisateur charge la page 1 des commandes triées par date la plus récente, lignes 1 à 25. Pendant qu'il lit, 3 nouvelles commandes arrivent. Il demande la page 2, qui est OFFSET 25. Les lignes 23, 24 et 25 de la première réponse ont maintenant été décalées aux positions 26 à 28. L'utilisateur les voit à nouveau. Doublons.

La suppression inverse la situation. Si 3 lignes sont supprimées de la page 1 pendant que l'utilisateur la lit, OFFSET 25 ignore désormais 3 lignes que l'utilisateur n'a jamais vues. Perte de données silencieuse, et personne ne reçoit d'erreur.

Pour un rapport mensuel que personne ne fait défiler en temps réel, la dérive est inoffensive. Pour un flux d'activité, un point de terminaison de synchronisation, ou tout ce qu'un script parcourt page par page pendant que les écritures continuent, la dérive signifie des enregistrements dupliqués ou manquants. Les consommateurs le remarquent.

Problème 2 : les offsets profonds scannent tout ce qu'ils ignorent

OFFSET 500000 ne téléporte pas à la ligne 500 001. La base de données parcourt l'index sur un demi-million d'entrées, les écarte, puis renvoie vos 25 lignes. Le coût augmente linéairement avec la profondeur : O(n) où n est l'offset.

Des chiffres concrets le rendent réel. Sur une table de commandes Postgres avec 2 millions de lignes et un index sur `created_at` :

L'article sur le "no-offset" de Markus Winand sur Use The Index, Luke, démontre ce coût avec des plans de requête et mérite d'être lu en entier. Le schéma en production est un journal de requêtes lentes dominé par des requêtes à offset élevé, souvent provenant d'un robot d'exploration qui parcourt consciencieusement chaque page de votre API publique. Un seul client, et votre p99 double.

Comment fonctionne la pagination par curseur

La pagination par curseur, également appelée pagination par jeu de clés, abandonne le compteur de lignes. Au lieu de dire « sauter 50 lignes », le client dit « donnez-moi les lignes après cet enregistrement spécifique ». Le curseur identifie la dernière ligne vue par le client, ce qui permet au serveur de se diriger directement vers le lot suivant.

Le SQL utilise une comparaison de lignes sur la clé de tri au lieu de OFFSET :

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;

Remarquez la comparaison sur deux colonnes. `created_at` seul n'est pas unique ; deux commandes peuvent arriver à la même milliseconde, et une clé de tri non unique signifie que des lignes sont ignorées ou répétées aux limites de page. L'ajout de `id` comme critère de départage rend le tri total et la pagination exacte. Avec un index composite sur `(created_at, id)`, la base de données se positionne directement à la limite et lit 25 entrées. La page 1 et la page 60 000 coûtent le même prix.

L'API ne devrait cependant pas exposer ces valeurs brutes. Les implémentations réelles encodent la clé de tri dans un jeton opaque, généralement en base64 :

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0

L'opacité est une décision de conception, pas une simple obfuscation. Les clients qui ne peuvent pas analyser le curseur ne peuvent pas construire d'URL manuellement, ce qui vous permet de modifier la clé de tri, d'ajouter une indication de shard, ou de changer de moteur de stockage sans rien casser. Le contrat devient « renvoyez ce que nous vous avons donné », rien de plus.

Le compromis : il n'y a pas de page 47. Un curseur ne connaît que « après cette ligne », donc les clients avancent (et reculent, si vous émettez un curseur précédent) une page à la fois. Les totaux ne sont pas non plus obtenus gratuitement ; le comptage est une requête distincte. Pour les conceptions où l'ensemble de données est énorme, notre guide sur la conception de la pagination d'API pour des millions d'enregistrements couvre l'aspect mise à l'échelle plus en profondeur.

Compromis en un coup d'œil

Dimension Pagination par offset Pagination par curseur
Sauter à une page arbitraire Oui, n'importe quel numéro de page Non, parcours séquentiel uniquement
Nombre total / nombre de pages Peu coûteux à inclure Requête de comptage séparée
Performance en page profonde O(n), se dégrade avec la profondeur O(1) par page à n'importe quelle profondeur
Stabilité lors des écritures Dérive : doublons et lacunes Stable, ancré à une ligne
Coût de construction Trivial Modéré : encodage, critères de départage, conception d'index
Exigences de classement Tout ORDER BY fonctionne Nécessite une clé de tri unique et indexée
Mise en cache des URL de page Facile, les URL sont prévisibles Plus difficile, les curseurs varient par parcours
Complexité client Faible Faible, si l'enveloppe est propre

Une subtilité de ce tableau mérite d'être soulignée : la pagination par curseur exige un tri déterministe. Si votre point de terminaison permet aux clients de trier par une colonne mutable et non unique comme `status`, la logique des jeux de clés devient rapidement difficile. L'offset tolère un ordre approximatif ; les curseurs le punissent.

Lequel choisir ?

Adaptez le style à la manière dont les données sont consommées.

Tables d'administration et tableaux de bord : offset. Outils internes avec quelques milliers de lignes, humains cliquant sur les numéros de page, et un décompte visible de « 1 848 résultats ». La dérive n'a pas d'importance, la profondeur reste faible, et la fonction « aller à la page » est une vraie fonctionnalité. L'offset l'emporte sur le coût de construction.

Flux à défilement infini : curseur. Personne ne saute à la page 47 d'un flux. Les utilisateurs ne chargent que « plus », les écritures se produisent constamment, et les doublons sont visibles et embarrassants. C'est le cas d'école du curseur.

API publiques : curseur. Vous ne contrôlez pas vos consommateurs. Quelqu'un écrira une boucle parcourant chaque page, et avec l'offset, les pages profondes deviendront votre problème à 3 heures du matin. Les curseurs maintiennent chaque page à faible coût et vous permettent de faire évoluer les mécanismes internes derrière le jeton opaque. Notre guide de pagination d'API REST couvre en détail les conventions d'URL et d'en-tête.

Exportations et tâches de synchronisation : curseur. Une tâche par lots tirant 2 millions de commandes nécessite deux garanties : aucune ligne manquée malgré les écritures concurrentes, et un coût fixe par page. L'offset ne fournit ni l'un ni l'autre. Un curseur vous offre également un point de reprise gratuit lorsque la tâche s'interrompt à la ligne 1,4 million.

La règle générale honnête : l'offset pour les interfaces petites, consultées par l'homme et riches en décomptes ; les curseurs pour tout ce qui est grand, en direct ou public.

Comment les API réelles gèrent cela

Stripe est entièrement basée sur les curseurs. Chaque point de terminaison de liste accepte `starting_after` (un ID d'objet) et `limit`, et les réponses incluent `has_more`. Pour récupérer la page suivante des prélèvements, vous passez l'ID du dernier prélèvement reçu. La documentation de pagination de Stripe montre le modèle ; notez qu'il n'y a pas de nombre total, une omission délibérée étant donné leur volume d'écriture.

L'API REST de GitHub expose toujours `page` et `per_page` sur la plupart des points de terminaison, avec des en-têtes `Link` pointant vers les pages suivante et précédente. Mais lisez attentivement la documentation de pagination de GitHub : ils demandent aux clients de suivre l'en-tête `Link` à la lettre au lieu de construire des URL de page, et les points de terminaison plus récents ont basculé vers les curseurs, précisément parce que les parcours profonds par offset sur des dépôts massifs posaient problème.

Slack a migré son API Web vers la pagination par curseur et la marque désormais comme l'approche utilisée par toutes les nouvelles méthodes. Des méthodes comme `conversations.history` renvoient `response_metadata.next_cursor`, et une chaîne de curseur vide signifie que vous avez atteint la fin, comme décrit dans la documentation de pagination de Slack.

Trois API à fort trafic, et la direction de voyage est unilatérale : vers les curseurs.

Conception de l'enveloppe de réponse

Une API à curseur réussit ou échoue en fonction de son enveloppe. Gardez-la 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"
}

Quatre règles la rendent solide :

Tester les deux styles dans Apidog

Les bugs de pagination se cachent aux limites : la dernière page, la page vide, le curseur dont la ligne d'ancrage a été supprimée. Le clic manuel ne les détectera pas, mais un scénario de test chaîné le fera, et c'est là qu'Apidog prend sa place dans le flux de travail.

Pour les points de terminaison à curseur, construisez un scénario de test en deux étapes :

  1. Appelez le point de terminaison et extrayez le curseur. Ajoutez un post-processeur à la première requête avec le JSONPath $.next_cursor, et stockez-le dans une variable comme nextCursor. Apidog vous permet de copier le JSONPath directement depuis le panneau de réponse ; le guide complet se trouve dans comment définir des assertions et extraire des variables avec JSONPath.
  2. Bouclez la requête de la page suivante. Enveloppez une deuxième requête dans une étape ForEach ou de boucle, passez {{nextCursor}} comme paramètre de curseur, ré-extrayez $.next_cursor à chaque itération, et sortez lorsque has_more est faux. Affirmez à chaque passage qu'aucun id ne se répète de la page précédente et que la taille de page ne dépasse jamais limit.

Pour les points de terminaison par offset, la même structure s'applique avec une variable de compteur : incrémentez `page`, vérifiez que la longueur de `data` est égale à `per_page` jusqu'à la dernière page, et vérifiez que `total` reste cohérent tout au long du parcours.

Ajoutez ensuite les cas limites comme étapes distinctes, chacune avec des assertions explicites :

Une fois le scénario validé localement, exécutez-le en CI à chaque fusion. Téléchargez Apidog gratuitement et vous pourrez avoir le scénario complet de parcours par curseur, boucles et assertions inclus, en moins d'une demi-heure.

FAQ

La pagination par curseur est-elle toujours meilleure ?

Non. L'offset est mieux adapté 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 décrit la plupart des outils d'administration internes. Les curseurs sont meilleurs lorsque l'ensemble de données est grand, les écritures sont fréquentes ou l'API est publique. Le mode d'échec est de choisir l'offset par défaut pour un point de terminaison de liste publique et de découvrir le coût O(n) après le lancement.

Comment obtenir un nombre total avec la pagination par curseur ?

Exécutez un `SELECT COUNT(*)` séparé avec les mêmes filtres, soit comme point de terminaison distinct, soit comme paramètre de requête optionnel comme `include_count=true`. Mettez-le en cache agressivement ; un nombre approximatif rafraîchi chaque minute satisfait presque toutes les interfaces utilisateur. Stripe ignore totalement les totaux, ce qui vous indique à quel point les clients en ont réellement besoin.

Puis-je proposer les deux styles de pagination sur un seul point de terminaison ?

Vous le pouvez, et GitHub le fait effectivement pendant sa transition, mais évitez cela sur les nouvelles API. Deux styles signifient deux ensembles de cas limites, deux matrices de test et une confusion chez le client quant à celui à utiliser. Choisissez-en un par point de terminaison. Si vous concevez le contrat à partir de zéro, les modèles de notre guide de pagination d'API REST maintiendront la cohérence des noms de paramètres sur toute votre surface.

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

Avec la pagination par jeu de clés, rien ne casse. La comparaison WHERE (created_at, id) < (?, ?) n'exige pas que la ligne d'ancrage existe ; elle cherche la position limite et continue. C'est un réel avantage par rapport aux conceptions de « curseur comme recherche de ligne », et c'est précisément le cas limite qu'il est utile de vérifier dans votre scénario de test Apidog avant qu'un consommateur ne le découvre pour vous.

Pratiquez le Design-first d'API dans Apidog

Découvrez une manière plus simple de créer et utiliser des API