Paginación por Cursor en PostgreSQL (Keyset vs OFFSET)
Implementa paginación eficiente por cursor para datasets grandes en PostgreSQL, evitando la degradación de rendimiento de OFFSET con paginación keyset indexada y ordenamiento estable
La paginación basada en offset (LIMIT 20 OFFSET 10000) se degrada linealmente a medida que los offsets crecen porque PostgreSQL debe escanear y descartar todas las filas precedentes. La paginación por cursor (keyset) usa columnas indexadas para buscar directamente el punto de inicio, manteniendo rendimiento de tiempo constante independientemente del tamaño del conjunto de datos. Esta receta implementa paginación por cursor con PostgreSQL, incluyendo codificación de cursor, navegación bidireccional y casos límite con claves de ordenamiento duplicadas. Para más contexto sobre por qué OFFSET se degrada, consulta la documentación de LIMIT de PostgreSQL.
Cuándo Usar Esto
- Fuentes de API con millones de elementos donde la navegación a páginas profundas es común
- Datos en tiempo real donde las filas se insertan continuamente, haciendo los conteos de offset inestables
- Necesitas resultados de página consistentes incluso cuando los datos subyacentes cambian entre peticiones
- APIs de GraphQL que usan conexiones estilo Relay y requieren paginación por cursor
Solución
1. Esquema de Base de Datos e Índice
-- migrations/001_create_posts.sql
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
title TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0
);
-- Índice compuesto para paginación por cursor por created_at
CREATE INDEX idx_posts_created_at_id ON posts (created_at DESC, id DESC);
-- Índice para paginación por score
CREATE INDEX idx_posts_score_id ON posts (score DESC, id DESC);
2. Codificación y Decodificación de Cursor
// pagination/Cursor.ts
import { Buffer } from 'buffer';
interface CursorData {
createdAt: string;
id: string;
}
function encodeCursor(data: CursorData): string {
const json = JSON.stringify(data);
return Buffer.from(json).toString('base64url');
}
function decodeCursor(cursor: string): CursorData {
const json = Buffer.from(cursor, 'base64url').toString('utf8');
return JSON.parse(json);
}
3. Consulta con Keyset Pagination
// pagination/PostRepository.ts
import { Pool } from 'pg';
interface PageResult<T> {
data: T[];
nextCursor: string | null;
prevCursor: string | null;
hasMore: boolean;
}
class PostRepository {
constructor(private pool: Pool) {}
async findPage(
limit: number = 20,
afterCursor?: string,
beforeCursor?: string
): Promise<PageResult<Post>> {
const client = await this.pool.connect();
try {
let query: string;
let params: unknown[];
if (afterCursor) {
const { createdAt, id } = decodeCursor(afterCursor);
query = `
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3
`;
params = [createdAt, id, limit + 1];
} else if (beforeCursor) {
const { createdAt, id } = decodeCursor(beforeCursor);
query = `
SELECT * FROM (
SELECT * FROM posts
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT $3
) sub
ORDER BY created_at DESC, id DESC
`;
params = [createdAt, id, limit + 1];
} else {
query = `
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1
`;
params = [limit + 1];
}
const result = await client.query(query, params);
const rows = result.rows;
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore && data.length > 0
? encodeCursor({ createdAt: data[data.length - 1].created_at, id: data[data.length - 1].id })
: null;
const prevCursor = data.length > 0
? encodeCursor({ createdAt: data[0].created_at, id: data[0].id })
: null;
return {
data,
nextCursor,
prevCursor: afterCursor || (!beforeCursor && data.length > 0) ? prevCursor : null,
hasMore,
};
} finally {
client.release();
}
}
}
4. Endpoint de API Express
// routes/posts.ts
app.get('/api/posts', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const after = req.query.after as string | undefined;
const before = req.query.before as string | undefined;
const page = await postRepo.findPage(limit, after, before);
res.json({
data: page.data,
pagination: {
nextCursor: page.nextCursor,
prevCursor: page.prevCursor,
hasMore: page.hasMore,
},
});
});
5. Navegación del Cliente
// client/PaginatedFeed.ts
class PaginatedFeed {
private nextCursor: string | null = null;
private prevCursor: string | null = null;
async loadNext(): Promise<Post[]> {
const params = new URLSearchParams();
params.set('limit', '20');
if (this.nextCursor) params.set('after', this.nextCursor);
const res = await fetch(`/api/posts?${params}`);
const page = await res.json();
this.nextCursor = page.pagination.nextCursor;
this.prevCursor = page.pagination.prevCursor;
return page.data;
}
async loadPrevious(): Promise<Post[]> {
if (!this.prevCursor) return [];
const params = new URLSearchParams();
params.set('limit', '20');
params.set('before', this.prevCursor);
const res = await fetch(`/api/posts?${params}`);
const page = await res.json();
this.nextCursor = page.pagination.nextCursor;
this.prevCursor = page.pagination.prevCursor;
return page.data;
}
}
Cómo Funciona
- Paginación keyset usa claves compuestas indexadas en lugar de OFFSET, habilitando búsquedas O(log n)
- Codificación de cursor oculta detalles de implementación y previene la manipulación de parámetros de consulta por los clientes
- Cursores bidireccionales soportan navegación hacia adelante y hacia atrás a través del mismo conjunto de datos
- Sobreobtención de 1 fila determina si existen más páginas sin una consulta COUNT separada
Consideraciones de Producción
- Crea siempre índices compuestos que coincidan con el orden exacto de ordenamiento usado en las consultas de paginación. Consulta la Guía de Optimización de SQL para estrategias de indexación.
- Usa
timestamptz(notimestamp) para evitar ambigüedad de zona horaria en los cursores - Valida la estructura del cursor para prevenir inyección mediante payloads base64 malformados. Consulta Validación de Entrada para patrones de validación.
Errores Comunes
- Paginar por una columna no única sin un desempate (ej.
created_atsolo), causando filas saltadas o duplicadas - Usar valores LIMIT grandes, que aún requieren un escaneo significativo del índice
- No manejar el caso donde la fila del cursor es borrada, rompiendo la navegación hacia adelante
Solución de Problemas
- Errores 5xx bajo carga: verifica límites de tasa, pools de conexión y tiempos de espera del backend.
- Errores CORS en el navegador: confirma orígenes, métodos y cabeceras permitidos. Las peticiones de pre-vuelo deben devolver las cabeceras correctas antes de la petición real.
- 404 inesperados: verifica definiciones de rutas, parámetros de ruta y rutas base. Vigila las barras finales y las diferencias de codificación de URL.
- Fallos de autenticación: valida expiración de tokens, algoritmos de firma y desviación de reloj. Registra tokens rechazados sin exponer secretos.
- Tiempos de respuesta lentos: perfila los percentiles más lentos.
Véase También
- Documentación de LIMIT y OFFSET de PostgreSQL — referencia oficial sobre por qué OFFSET se degrada.
- Especificación de Relay Cursor Connections — la especificación canónica para paginación por cursor en GraphQL.
- Paginación keyset en PostgreSQL — análisis profundo de Markus Winand sobre el rendimiento de keyset vs OFFSET.
- pg_stat_statements — seguimiento del rendimiento de consultas para identificar paginación lenta.
/recipes/go-rest-api-gin— construcción de APIs REST con Go y Gin./recipes/api-documentation-openapi— documentación de paginación en especificaciones OpenAPI./recipes/graphql-pagination-relay-connections— implementación de paginación por cursor estilo Relay en GraphQL./recipes/database-replication— escalado de lecturas entre réplicas para APIs paginadas de alto tráfico.
Preguntas frecuentes
¿Debería usar paginación por offset alguna vez?
Solo para datasets pequeños (< 10.000 filas) o interfaces de admin donde saltar a páginas arbitrarias es requerido.
¿Cómo manejo ordenamiento por múltiples columnas?
Incluye todas las columnas de ordenamiento en el índice compuesto y codifica todos los valores en el cursor.
¿Cómo codifico un cursor de forma segura para URLs?
Codifica en base64 el contenido del cursor (JSON o valores concatenados) y luego codifica el resultado para URL. Usa codificación base64url (reemplaza + con -, / con _, quita el relleno =) para evitar caracteres que necesiten codificación de URL. En el servidor, revierte la codificación para extraer los valores del cursor. Nunca pases valores SQL en bruto en el cursor — siempre codifícalos para prevenir manipulación.
¿Cómo manejo paginación por cursor con claves primarias UUID?
Los UUID no son naturalmente ordenados. Agrega una columna created_at de tipo timestamp con un índice y usa (created_at, id) como cursor compuesto. Si necesitas distribución aleatoria, usa UUIDv7 (ordenado por tiempo) en lugar de UUIDv4. Para tablas existentes con UUIDv4, agrega una columna serial o bigserial y úsala como clave del cursor.
¿Cómo implemento paginación por cursor bidireccional (página anterior)?
Guarda el primer y último cursor de la página actual en el cliente. Para la página anterior, invierte el orden de ordenamiento y consulta WHERE (created_at, id) < (previous_first_cursor_values) con ORDER BY created_at DESC, id DESC. Luego invierte los resultados en el cliente para mantener el ordenamiento consistente. Incluye indicadores has_previous_page y has_next_page en la respuesta.
¿Cómo manejo paginación por cursor con consultas filtradas?
Aplica el filtro WHERE antes de la condición del cursor. El cursor sigue usando las columnas de ordenamiento: WHERE (status = 'active') AND (created_at, id) < (cursor_values) ORDER BY created_at DESC, id DESC LIMIT 20. Asegúrate de que la columna del filtro tenga un índice junto a las columnas de ordenamiento. Para filtros dinámicos, usa un índice compuesto en (filter_column, created_at, id).
¿Qué pasa si un cursor referencia una fila eliminada?
Nada se rompe — la paginación por cursor usa comparación de rango (< o >), no búsqueda de fila. La consulta simplemente retorna las siguientes filas después de la posición del cursor, sin importar si la fila original aún existe. Esta es una ventaja clave sobre la paginación por offset, que puede saltar o duplicar filas cuando los datos cambian entre peticiones.
¿Cómo manejo paginación por cursor con ordenamiento por tiempo?
Usa (created_at, id) como clave del cursor para asegurar ordenamiento estable cuando dos o más filas comparten el mismo timestamp. Crea un índice compuesto en (created_at DESC, id DESC) que coincida con tu dirección de ordenamiento. Cuando dos filas tienen created_at idéntico, el id de desempate garantiza un ordenamiento determinista. Evita usar updated_at como clave de ordenamiento si las filas pueden ser actualizadas concurrentemente — la posición del cursor puede cambiar.
¿Cómo implemento paginación por cursor en conexiones GraphQL?
Sigue la especificación de Relay Connection: retorna edges con campos node y cursor, más pageInfo con hasNextPage, hasPreviousPage, startCursor, y endCursor. Codifica los cursores como cadenas base64. En el servidor, decodifica el cursor, extrae los valores de ordenamiento, y consulta con WHERE (created_at, id) < (cursor_values). Los argumentos first y last mapean a LIMIT.
¿Cómo mido el rendimiento de la paginación por cursor?
Usa EXPLAIN ANALYZE para verificar que la consulta usa el índice compuesto y realiza un escaneo de índice, no un escaneo secuencial. Comprueba que el tiempo de ejecución se mantenga constante a medida que el cursor se mueve más profundo en el conjunto de datos. Monitorea la latencia de consulta en producción con pg_stat_statements. Compara la latencia p99 entre la primera página y la página 10000 — la paginación por cursor debería mostrar un rendimiento plano, a diferencia de offset que se degrada linealmente.
Recursos Relacionados
REST API en Go con Gin y Middleware
Construye APIs REST listas para producción en Go usando el framework Gin con middleware custom para logging, autenticación, validación y manejo de errores.
RecipeOpenAPI 3.1: docs con Swagger UI y Redoc
Guía práctica para documentar APIs REST con OpenAPI. Genera docs interactivas con Swagger UI y Redoc en Python, JavaScript y Java con linting en CI.
RecipeReplicación de Bases de Datos
Configura y gestiona replicación de bases de datos para alta disponibilidad, escalado de lecturas y disaster recovery con arquitecturas primaria-réplica.
RecipeEvolución de Schema de Base de Datos
Evoluciona schemas de base de datos de forma segura con cambios backward-compatible, migraciones versionadas y operaciones DDL online en ambientes de producción.
RecipePaginacion por cursores con GraphQL Relay Connections
Implementa paginacion estilo Relay con cursores en GraphQL usando edges, nodes y pageInfo para recorrido eficiente hacia adelante y atras