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` :
LIMIT 25 OFFSET 0lit 25 entrées d'index. Quelques millisecondes.LIMIT 25 OFFSET 100000lit 100 025 entrées et en jette 100 000. Des dizaines de millisecondes.LIMIT 25 OFFSET 1500000lit 1,5 million d'entrées. Vous êtes maintenant dans des centaines de millisecondes, retenant des tampons et consommant du CPU pour une seule page.
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 :
- Toujours renvoyer `has_more`. Les clients ne doivent pas déduire la fin d'une page courte ; une page peut être courte en milieu de flux si vous filtrez après la récupération.
- Renvoyer `next_cursor: null` sur la dernière page, et le documenter. La convention de chaîne vide de Slack fonctionne également ; choisissez-en une et ne les mélangez jamais.
- Rejeter les curseurs invalides avec un 400, pas un 200 vide. Un curseur corrompu est un bug client, et le masquer coûte une journée de débogage à quelqu'un.
- Signez ou versionnez la charge utile du curseur si elle encode autre chose que des clés de tri. Vous vous en remercierez lors de la prochaine migration de schéma.
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 :
- 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 commenextCursor. 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. - 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 lorsquehas_moreest faux. Affirmez à chaque passage qu'aucunidne se répète de la page précédente et que la taille de page ne dépasse jamaislimit.
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 :
- Page vide : demandez un filtre correspondant à zéro ligne ; affirmez que `data` est `[]`, `has_more` est faux, et le statut est 200.
- Curseur invalide : envoyez `cursor=not-a-real-cursor` ; affirmez que le statut est 400 et qu'un code d'erreur lisible par machine est présent.
- Ligne d'ancrage supprimée : créez une commande, saisissez un curseur ancré à celle-ci, supprimez la commande, puis utilisez le curseur ; affirmez que le parcours continue à partir de la position correcte au lieu de générer une erreur. Les comparaisons par jeu de clés gèrent cela naturellement, et le test le prouve.
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.
