Tri stable en pagination : éviter que les éléments de liste se déplacent
Découvrez comment un tri stable en pagination garantit un ordre cohérent des listes en ajoutant des clés de départage, en choisissant des champs sûrs et en faisant des vérifications rapides pour éviter les réarrangements.

À quoi ressemble le « réarrangement des pages de liste » dans les vraies applications
Vous ouvrez une liste, allez à la page 2, et repérez un élément que vous jurez avoir vu sur la page 1 il y a une seconde. Vous actualisez et l'ordre change à nouveau. Parfois un élément apparaît sur les deux pages. Parfois il disparaît jusqu'à ce que vous modifiiez un filtre.
C'est ce qu'on appelle le « réarrangement des pages de liste ». L'application pagine, mais l'ordre de tri n'est pas complètement fixé, donc la base de données (ou l'API) renvoie les enregistrements dans un ordre légèrement différent d'une requête à l'autre.
Ça peut sembler mineur, mais ça mine vite la confiance. Les utilisateurs pensent que des données manquent, que les notifications ne sont pas fiables, ou que quelqu'un a modifié les enregistrements. Dans les écrans d'administration et de reporting, cela peut provoquer de vraies erreurs : revoir deux fois la même ligne et en zapper une autre.
On le remarque surtout dans les tableaux d'administration, les flux d'activité, les résultats de recherche, les logs d'audit et les catalogues produits.
« Stable » ne signifie pas que la liste ne change jamais. Cela signifie tri stable sous pagination : si les entrées sont les mêmes (mêmes filtres, même option de tri, même snapshot de données), vous obtenez le même ordre à chaque fois.
Une liste stable a trois caractéristiques :
- Les égalités sont départagées toujours de la même façon (pas d'ordre « aléatoire » pour des enregistrements partageant la même valeur de tri).
- Les limites de page sont prévisibles (un élément est soit sur la page 1 soit sur la page 2, pas les deux).
- Actualiser ne réorganise pas les éléments sauf si les données sous-jacentes ont changé.
Un déclencheur fréquent est de trier par un champ qui a souvent des doublons, comme created_at, status ou score. Si dix éléments partagent le même timestamp ou le même score, la base peut renvoyer ces dix éléments dans n'importe quel ordre à moins que vous n'indiquiez un départage.
Pourquoi la pagination casse quand le tri n'est pas déterministe
La pagination repose sur une hypothèse simple : exécuter deux fois la même requête renvoie le même ordre.
Quand l'ordre n'est pas garanti, votre « page 2 » n'est plus vraiment la page 2. Les utilisateurs voient des répétitions, des éléments manquants, ou des lignes qui semblent sauter.
La cause habituelle est les égalités. Votre requête trie par quelque chose comme created_at, score, ou name, et plusieurs lignes partagent la même valeur. Dans ce cas, la base de données est autorisée à renvoyer les lignes ex æquo dans n'importe quel ordre à moins que vous ne précisiez une règle claire pour départager. Cet « ordre au hasard » peut changer entre les requêtes.
La pagination par offset rend cela particulièrement visible parce que les offsets comptent des positions, pas des lignes spécifiques. Si les positions changent, les offsets pointent vers une tranche différente de la liste.
Même si vos données ne changent pas, les égalités peuvent quand même inverser l'ordre pour des raisons que vous ne contrôlez pas : un index différent est utilisé, le plan de requête change après une mise à jour des statistiques, ou l'exécution en parallèle renvoie des lots dans un ordre distinct. C'est un comportement normal lorsque vous ne demandez pas un ordre déterministe.
Choisir une règle d'ordonnancement stable (clé primaire plus départage)
Pour arrêter le réarrangement, il vous faut une règle d'ordre qui ne laisse jamais d'égalité non résolue.
Choisissez le tri attendu par les utilisateurs
Commencez par le champ qui correspond à la façon de penser des gens.
- Flux d'activité : les plus récents en premier.
- Tableau de classement : meilleur score en premier.
- Annuaire : nom de A à Z.
Puis supposez que des égalités vont survenir. Beaucoup d'événements partagent le même timestamp, beaucoup d'utilisateurs ont le même nom de famille, et beaucoup d'articles partagent le même score arrondi.
Ajoutez un départage qui ne change jamais
Ajoutez une seconde clé de tri qui soit unique et stable. La plupart des applications en ont déjà une : une clé primaire comme id. Le départage ne doit jamais changer dans le temps et doit exister sur chaque ligne.
Soyez explicite sur la direction pour chaque clé. Si votre tri principal est en DESC (les plus récents en premier), utiliser DESC pour le départage garde le comportement intuitif.
Une règle simple réutilisable partout :
- Trier d'abord par le champ visible par l'utilisateur.
- Trier ensuite par
idpour départager. - Définir la direction pour les deux champs.
- Utiliser exactement la même règle partout où la même liste est servie.
Écrivez-la en une phrase, par exemple : « Afficher les éléments les plus récents en premier ; si les timestamps sont identiques, afficher les id les plus élevés en premier. » Cela évite la dérive où un endpoint utilise un ordre et un autre endpoint un ordre légèrement différent.
Étape par étape : ajouter des départages à vos requêtes
La correction la plus rapide est presque toujours la même : gardez votre tri principal, puis ajoutez un départage unique.
Commencez par la requête exacte que votre API exécute. Trouvez le ORDER BY actuel et demandez-vous : deux lignes peuvent-elles avoir les mêmes valeurs pour ces champs de tri ? Si oui, vous avez une égalité, et la base peut renvoyer ces lignes ex æquo dans des ordres différents.
Un schéma courant pour « les plus récents en premier » ressemble à ceci :
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
Si vous triez par un champ non unique comme created_at, price ou score, incluez toujours id (ou une autre colonne unique) comme dernier critère de tri.
Un détail pratique : vérifiez votre index. Pour l'exemple ci‑dessus, un index composite comme (status, created_at, id) (la direction dépend de votre base) évite des tris lents et rend les performances plus prévisibles.
Offset vs pagination par curseur : ce qui change pour le tri stable
La pagination par offset est l'approche classique « page=3 » : trier, sauter les N premières lignes, puis prendre le prochain lot. C'est simple à implémenter, mais ça suppose que l'ordre reste stable.
La pagination par curseur est l'approche « after=item_123 » : au lieu de sauter des lignes, vous récupérez les items après le dernier élément que vous avez déjà. Elle performe souvent mieux et évite certains cas limites, mais uniquement si votre ordre de tri est stable et unique.
La pagination par curseur rend l'exigence explicite : le curseur doit décrire une position précise dans un ordre déterministe. Cela signifie généralement utiliser un tri composite et un curseur composite.
Par exemple : triez par created_at DESC, id DESC, et faites porter votre curseur sur ces deux valeurs. Si vous ne stockez que created_at dans le curseur, la frontière est ambiguë dès que plusieurs lignes partagent le même timestamp.
Règles pratiques :
- Incluez toujours un départage unique dans
ORDER BY. - Faites correspondre le curseur au
ORDER BYcomplet (mêmes champs, mêmes directions). - Si les utilisateurs changent de filtres ou d'options de tri, traitez cela comme une nouvelle requête et recommencez avec un curseur neuf.
Nouvelles données et mises à jour : garder les résultats cohérents dans le temps
Même avec un ORDER BY parfait, les pages peuvent sembler instables si les données sous-jacentes changent entre les requêtes. Le tri est déterministe, mais l'ensemble de données bouge.
Les nouveaux éléments sont le problème classique avec la pagination par offset. Si vous récupérez la page 1 (éléments 1–20), puis qu'un nouvel élément arrive en tête, votre requête suivante pour la page 2 (offset 20) commence à partir de ce qui était auparavant l'élément 21, mais tout a décalé. Les utilisateurs voient des doublons ou ratent des éléments.
Les éditions sont parfois pires. Si votre champ de tri change (par exemple updated_at), une ligne existante peut sauter entre les pages.
La correction commence par une décision produit : cette liste doit‑elle être « live », ou doit‑elle rester cohérente pendant une session ?
Si vous souhaitez de la cohérence, ancrez les résultats à un point de snapshot. Approches courantes :
- S'ancrer à un timestamp fixe (ne montrer que les éléments avec
created_at <=l'heure du premier chargement). - S'ancrer à une frontière de curseur (ne montrer que les éléments en dessous du curseur supérieur de la première page).
- Éviter de trier les feeds par
updated_atsauf si c'est vraiment ce que les utilisateurs attendent. - Afficher une bannière « Nouveaux éléments » et laisser l'utilisateur rafraîchir volontairement.
Exemple : un flux d'activité trié par updated_at DESC. Un utilisateur ouvre la page 1, puis quelqu'un modifie un ancien enregistrement, met à jour updated_at et le remonte en tête. Quand l'utilisateur charge la page 2, il voit une entrée qu'il a déjà lue, et une autre a disparu. S'ancrer à l'heure du premier chargement, ou basculer le flux sur created_at, supprime ce comportement instable.
Erreurs courantes qui font sauter les éléments entre les pages
La plupart des bugs « éléments qui bougent » sont des bugs de tri.
Coupables fréquents :
- Trier uniquement par un champ non unique comme
created_at,statusounamesans départage. - Utiliser un ordre aléatoire (ou un score qui change) et le traiter comme une liste stable.
- Paginer en SQL, puis trier côté application après récupération.
- Différents endpoints qui utilisent des tris par défaut différents pour la même liste.
- Oublier de définir où vont les valeurs
NULL.
Des versions subtiles du même problème apparaissent aussi lorsque les étiquettes UI ne correspondent pas au tri réel (par exemple, afficher « Dernière mise à jour » mais trier par « Created »). Les utilisateurs vivent alors l'effet de réarrangement parce que la liste ne se comporte pas comme l'écran l'indique.
Vérifications rapides avant de déployer
Ces bugs passent souvent parce que la requête a l'air correcte dans une petite base de dev, puis échoue avec le volume réel et les modifications en production.
Une check‑list courte :
- Terminez votre ordre par un champ unique (souvent
id) pour que deux lignes ne puissent jamais ex æquo. - Spécifiez
ASC/DESCpour chaque champ ordonné. - Appliquez le tri dans la requête de la base de données avant
LIMIT/OFFSET(ou avant le filtre du curseur), pas après la récupération. - Si vous utilisez la pagination par curseur, incluez tous les champs de tri dans le curseur.
- Vérifiez que vous avez un index correspondant à vos filtres habituels plus l'ordre.
Un test de réalité rapide : chargez la page 1 et la page 2, puis insérez une nouvelle ligne qui fait égalité sur le champ de tri principal (même seconde, même score, etc.). Rechargez. Si des éléments intervertissent leurs positions ou apparaissent sur les deux pages, vous avez encore une égalité non résolue.
Exemple : corriger un flux d'activité qui se réorganise
Une équipe met en ligne un flux d'activité construit avec un outil de programmation assistée par IA. Tout semble correct en test, mais les utilisateurs se plaignent : « J'ai vu un élément sur la page 2, j'ai rafraîchi, il est passé en page 1. » L'équipe blâme le cache, mais le vrai problème est le tri.
Le flux ne trie que par created_at DESC. En production, de nombreuses lignes partagent le même timestamp (insertions par lot, jobs en arrière‑plan, ou faible précision du timestamp). Quand plusieurs items ont le même created_at, la base peut les renvoyer dans n'importe quel ordre, donc les frontières de page vacillent.
Avant :
SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;
Après (déterministe) :
SELECT *
FROM activities
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;
Si vous utilisez la pagination par curseur, mettez à jour le curseur pour qu'il porte les deux champs. Au lieu de « dernier created_at vu », utilisez un curseur composé comme (created_at, id), et récupérez les éléments « inférieurs » à cette paire tout en gardant le même ordre.
Comment tester et surveiller la stabilité en production
La définition la plus simple du succès : la même requête, faite deux fois de suite, renvoie les mêmes IDs d'items dans le même ordre (sauf si vous autorisez intentionnellement l'apparition de nouveaux éléments).
Bons tests :
- Récupérer la page 1 deux fois avec les mêmes paramètres et comparer les IDs retournés dans l'ordre.
- Récupérer la page 2, puis la page 1 à nouveau, et vérifier que la page 1 n'a pas changé.
- Affirmer que chaque ID d'item apparaît au maximum une fois entre la page 1 et la page 2.
- Vérifier l'ordre en contrôlant que
(sort_field, tie_breaker)est strictement monotone.
Quand les utilisateurs remontent un réarrangement, consignez ce qu'il faut pour le rejouer : filtres, limit/offset ou valeurs de curseur, et les champs d'ordonnancement complets (y compris le départage).
Après avoir changé l'ordre ou les index, surveillez la latence des requêtes (surtout p95) et les logs des requêtes lentes. Si les performances se dégradent, c'est généralement un problème d'indexation, pas une raison d'abandonner l'ordre déterministe.
Prochaines étapes : rendre l'ordre cohérent dans toute l'application
Une fois qu'une page est corrigée, le bug suivant apparaît souvent ailleurs parce que la même logique de liste existe à plusieurs endroits.
Faites un inventaire rapide de tous les endroits où vous renvoyez une liste : écrans utilisateurs, tableaux d'administration, recherche, exports et jobs en arrière‑plan qui paginent des données. Puis appliquez une politique partagée : chaque liste a un ORDER BY déterministe qui se termine par un départage unique, et chaque endpoint l'utilise.
Si vous avez hérité d'un code généré par IA où les requêtes de liste ont été copiées et modifiées par écran, un audit ciblé de l'ordonnancement peut rapporter vite. Les équipes amènent parfois ce type de problème à FixMyMess (fixmymess.ai) quand les feeds et tableaux d'administration continuent de se réorganiser en production, car il s'agit souvent de départages manquants et de règles d'ordonnancement incohérentes entre endpoints.