PostgreSQL
Paginación con OFFSET y keyset
Comparación entre paginación con OFFSET y keyset pagination, considerando estabilidad, índices, orden total y rendimiento en páginas profundas.
- Última actualización
- Actualizada
- Nivel
- Aplicación
PostgreSQL
Comparación entre paginación con OFFSET y keyset pagination, considerando estabilidad, índices, orden total y rendimiento en páginas profundas.
Paginar no es solo limitar filas. Es definir un orden total, una posición reproducible y qué debe ocurrir cuando los datos cambian entre solicitudes.
Dos estrategias principales:
OFFSET
→ descartar N filas y devolver las siguientes
KEYSET
→ continuar después del último valor ordenado vistoAmbas necesitan ORDER BY. Sin orden no existe concepto estable de página.
SELECT id, created_at, total_amount
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000;Ventajas:
Costes:
ORDER BY created_at DESC, id DESCcreated_at solo no basta si varias filas comparten timestamp. El desempate único hace total el orden.
El índice debe alinearse:
CREATE INDEX orders_created_id_idx
ON orders(created_at DESC, id DESC);Primera página:
SELECT id, created_at, total_amount
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;Siguiente:
SELECT id, created_at, total_amount
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;El cursor representa la última pareja vista.
La comparación de tuplas es lexicográfica:
(created_at menor)
o
(created_at igual e id menor)Debe usar las mismas columnas, dirección y semántica del orden.
Para un orden mixto, la condición puede necesitar lógica explícita. NULL complica cursores:
ORDER BY delivered_at DESC NULLS LAST, id DESCDiseña un cursor que represente también si el valor es NULL o evita ordenar por una columna nullable sin normalización clara.
No expongas SQL libre. Codifica datos estructurados:
{"createdAt":"2026-07-24T12:00:00Z","id":"123"}Luego firma o autentica el cursor si alterar filtros/tenant podría dar acceso incorrecto.
Valida:
El cursor solo es válido para la misma consulta lógica. Si cambia status, tenant o sort, debe rechazarse o reiniciarse.
Puede incluir un hash de filtros:
cursor = version + order values + query fingerprintInvierte comparación y orden, obtiene filas y luego revierte en aplicación:
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT 20;El contrato debe distinguir next y previous.
Keyset evita que inserts anteriores desplacen el offset, pero no crea snapshot entre requests. Una fila actualizada puede cambiar de posición y aparecer otra vez o desaparecer.
Opciones cuando se necesita fotografía estable:
created_at <= snapshot_time.count(*) OVER() puede calcular el total en la misma query, pero obliga a procesar todo el conjunto aunque devuelvas 20 filas. Para grandes datasets:
Consulta:
WHERE tenant_id = $1
AND status = $2
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESCÍndice candidato:
CREATE INDEX orders_tenant_status_cursor_idx
ON orders(tenant_id, status, created_at DESC, id DESC);No crees índices para cada combinación sin medir workload.
WHERE id < $1 ORDER BY id DESCEs sencillo si ID define el orden requerido. Pero sequence no representa necesariamente commit time y no sirve si la UI ordena por otra propiedad.
Úsalo para:
No conviertas keyset en complejidad innecesaria.
created_at solo puede ser insuficiente?COPY, importación y exportación mueve grandes volúmenes sin convertir cada fila en un viaje de red.