Ordenamiento y paginación en MongoDB | Nicolás Garzón
Inicio Wiki MongoDB Ordenamiento y paginación Volver a MongoDBMongoDB
Ordenamiento y paginación Explica sort, límites, skip, paginación por cursor y desempates estables para recorrer resultados sin duplicados ni saltos bajo cambios concurrentes.
Última actualización Actualizada 25 de jul de 2026
Ordenar y paginar significa entregar un subconjunto estable y reproducible de resultados sin recorrer ni transferir toda la colección. Una paginación correcta necesita un , un índice compatible y una estrategia explícita frente a cambios concurrentes.
Nota anteriorProyección Nota siguiente Actualizaciones orden total
Texto
Copiar filter
↓
sort estable
↓
limit
↓
continuation tokenMongoDB no garantiza un orden de negocio si la query no usa sort.
JavaScript
Copiar db. orders. find ( { businessId } ) ; Puede parecer consistente en desarrollo, pero cambiar por índices, inserts, compactación o plan elegido.
JavaScript
Copiar db. orders. find ( { businessId } )
. sort ( { createdAt : - 1 , _id : - 1 } )
. limit ( 20 ) ; _id actúa como tie-breaker cuando varios documentos comparten createdAt.
Un sort es total cuando dos documentos distintos no quedan empatados.
JavaScript
Copiar { createdAt : - 1 } Si cien órdenes tienen el mismo milisegundo, su posición relativa no está definida.
JavaScript
Copiar { createdAt : - 1 , _id : - 1 } Esto permite construir un cursor inequívoco.
JavaScript
Copiar {
businessId,
status : 'pending'
} JavaScript
Copiar { createdAt : - 1 , _id : - 1 } JavaScript
Copiar db. orders. createIndex ( {
businessId : 1 ,
status : 1 ,
createdAt : - 1 ,
_id : - 1
} ) ; Si el índice no soporta el orden, MongoDB puede ejecutar un sort adicional. explain() permite detectar stages de sort y límites de memoria.
La forma clásica usa skip y limit:
JavaScript
Copiar db. orders. find ( filter)
. sort ( { createdAt : - 1 , _id : - 1 } )
. skip ( 2000 )
. limit ( 20 ) ;
fácil de implementar;
permite saltar a una página numérica;
adecuada para conjuntos pequeños o administración ocasional.
MongoDB debe avanzar sobre los resultados anteriores para llegar al offset. El coste crece con páginas profundas.
Además, inserts o deletes entre requests pueden desplazar resultados:
Texto
Copiar página 1
→ se inserta una orden nueva
→ página 2 con skip puede repetir o saltar elementos
Utiliza los últimos valores del sort como frontera.
JavaScript
Copiar db. orders. find ( filter)
. sort ( { createdAt : - 1 , _id : - 1 } )
. limit ( 20 ) ; JavaScript
Copiar const nextFilter = {
... filter,
$or : [
{ createdAt : { $lt : lastCreatedAt } } ,
{
createdAt : lastCreatedAt,
_id : { $lt : lastId }
}
]
} ; La comparación refleja el orden descendente. Para ascendente se invierten operadores.
No expongas necesariamente la estructura interna como parámetros sueltos. Puedes firmar o codificar un token:
JSON
Copiar {
"createdAt" : "2026-07-24T12:00:00.000Z" ,
"id" : "66a2..." ,
"filtersHash" : "..." ,
"version" : 1
}
formato;
versión;
tipos;
expiración si aplica;
firma para evitar manipulación;
compatibilidad con filtros y sort actuales.
Un cursor no debe permitir cambiar de tenant o filtro entre páginas.
TypeScript
Copiar const pageSize = Math. min ( input. limit ?? 20 , 100 ) ;
const filter: Filter< OrderDocument> = {
businessId: session. businessId,
status: input. status
} ;
if ( input. cursor) {
const cursor = decodeAndVerifyCursor ( input. cursor) ;
filter. $or = [
{ createdAt: { $lt: cursor. createdAt } } ,
{
createdAt: cursor. createdAt,
_id: { $lt: cursor. id }
}
] ;
}
const documents = await orders. find ( filter)
. sort ( { createdAt: - 1 , _id: - 1 } )
. limit ( pageSize + 1 )
. toArray ( ) ; Se solicita un elemento adicional para saber si existe siguiente página. Ese elemento no se devuelve; se usa para construir nextCursor.
Para navegar hacia atrás debes invertir temporalmente comparación y sort, obtener resultados y luego restaurar el orden de presentación. La implementación es más compleja y debe probar empates y extremos.
No prometas navegación bidireccional si la API solo necesita “cargar más”.
Cursor pagination evita el coste profundo de skip, pero no crea una snapshot eterna.
Con un orden descendente, inserts más recientes suelen quedar antes de la frontera y no alteran las páginas ya recorridas.
Si createdAt o el campo principal cambia, un documento puede moverse entre páginas. Utiliza un campo inmutable cuando sea posible.
Un documento eliminado simplemente desaparece. El número total cambia.
Si el usuario necesita una vista exactamente consistente durante todo el recorrido, se requiere una estrategia adicional: snapshot read dentro de un contexto compatible, export job, materialización o corte temporal.
Para feeds puede fijarse un asOf:
JavaScript
Copiar {
createdAt : { $lte : asOf }
} Todas las páginas usan el mismo corte. Esto evita incluir nuevos documentos, pero updates y deletes todavía requieren una política.
Devolver totalPages exige un count exacto:
JavaScript
Copiar const total = await orders. countDocuments ( filter) ; El count puede ser más costoso que la página y no necesariamente observa la misma vista si los datos cambian.
no devolver total;
devolver hasNextPage;
estimación;
contador materializado;
count asíncrono;
total solo en filtros pequeños.
Collation afecta orden lingüístico. El índice debe tener collation compatible.
JavaScript
Copiar db. customers. createIndex (
{ businessId : 1 , name : 1 } ,
{ collation : { locale : 'es' , strength : 2 } }
) ; Una query con collation distinta puede no utilizar ese índice.
MongoDB tiene un orden BSON entre tipos. Si el mismo campo contiene dates, strings y null, el resultado puede ser técnicamente determinista pero semánticamente incorrecto.
Validation y migración deben converger tipos antes de depender del sort.
Ordenar por arrays utiliza reglas específicas para seleccionar valores representativos. Puede producir resultados inesperados. Evita usar arrays como clave principal de paginación sin comprender y probar la semántica.
TypeScript
Copiar const limit = Math. min ( Math. max ( input. limit ?? 20 , 1 ) , 100 ) ; Un límite enorme aumenta:
memoria del servidor y aplicación;
red;
tiempo de serialización;
duración del cursor;
impacto de cancelaciones.
Valida sort permitido. No aceptes:
TypeScript
Copiar collection. find ( filter) . sort ( request. query. sort) ; Una allowlist evita campos sensibles o sorts costosos:
TypeScript
Copiar const sorts = {
newest: { createdAt: - 1 , _id: - 1 } ,
oldest: { createdAt: 1 , _id: 1 } ,
totalDesc: { total: - 1 , _id: - 1 }
} as const ; Cada opción debe tener un índice o un coste aceptado.
Devuelve items: [] y nextCursor: null.
La frontera sigue siendo válida porque contiene valores, no necesita que el documento exista.
Debe rechazarse sin ejecutar una query arbitraria.
El tie-breaker evita duplicados y omisiones.
Un cursor creado para pending no debe reutilizarse para confirmed.
Incluye versión en el token y decide compatibilidad.
El coste crece con la profundidad.
Produce sort costoso o falla por límites operativos.
Genera páginas inestables.
Permite manipular filtros o tenant.
El count tiene coste y puede cambiar.
Los documentos se mueven mientras se navega.
Crea muchos empates del campo principal.
Inserta documentos entre páginas.
Elimina el documento frontera.
Cambia un campo de orden.
Prueba cursor inválido y de otro filtro.
Ejecuta páginas profundas.
Revisa explain() e índice.
Mide payload y page size.
Prueba collation y tipos mixtos.
Verifica navegación hacia atrás si existe.
La paginación es una operación de consistencia y rendimiento. Un cursor no es solo un ID: codifica una posición dentro de un orden total y unos filtros. Cursor pagination escala mejor que offsets profundos, pero necesita índices, validación y una política frente a cambios concurrentes.
Comprueba lo aprendido
¿Por qué createdAt solo no garantiza orden total?
¿Qué coste introduce skip en páginas profundas?
¿Cómo se construye la condición para la siguiente página?
¿Qué debe proteger un token opaco?
¿Por qué el total exacto puede no coincidir con los items?
¿Qué ocurre si el campo de sort cambia durante la navegación?