Paginación en SQL: LIMIT y OFFSET frente a cursor

Mostrar resultados de 20 en 20 parece trivial: LIMIT 20 OFFSET 40 y listo. La paginación con OFFSET funciona, pero tiene dos problemas que aparecen con datos reales: filas que se repiten o se saltan entre páginas, y páginas lejanas cada vez más lentas. Los dos tienen solución.

Todas las consultas usan la base de ejemplo «tienda» del curso (clientes, productos, pedidos y sus líneas). Las que funcionan en SQLite tienen un enlace para abrirlas en el editor de SQL, que la trae cargada.

LIMIT y OFFSET

Página 1 y página 2, de cinco pedidos cada una, del más reciente al más antiguo. OFFSET 5 se salta los cinco primeros:

-- Página 1 y página 2, de 5 pedidos cada una, del más reciente al más antiguo
SELECT id, fecha, estado FROM pedidos
ORDER BY fecha DESC, id DESC
LIMIT 5;

SELECT id, fecha, estado FROM pedidos
ORDER BY fecha DESC, id DESC
LIMIT 5 OFFSET 5;

Ábrelo en el editor y cámbialo →

resultado · SQLiteid | fecha      | estado
---+------------+----------
22 | 2026-10-12 | pendiente
21 | 2026-10-02 | pendiente
20 | 2026-09-23 | enviado
19 | 2026-09-09 | enviado
18 | 2026-08-21 | entregado

id | fecha      | estado
---+------------+----------
17 | 2026-08-05 | entregado
16 | 2026-07-14 | entregado
15 | 2026-07-01 | entregado
14 | 2026-06-17 | entregado
13 | 2026-06-02 | cancelado

PostgreSQL y MySQL devuelven lo mismo. MySQL acepta además LIMIT 5, 5 (primero el desplazamiento), y PostgreSQL, la forma del estándar SQL:

-- La forma del estándar SQL, que PostgreSQL también entiende
SELECT id, fecha FROM pedidos
ORDER BY fecha DESC, id DESC
OFFSET 5 ROWS FETCH FIRST 5 ROWS ONLY;

Siempre un desempate en el ORDER BY

Si el orden no es único, el motor puede devolver las filas empatadas en cualquier orden, y ese orden puede cambiar entre una página y la siguiente. La misma consulta en los tres motores:

-- Ordenar solo por una columna con valores repetidos: ¿qué pedido sale tercero?
SELECT id, estado FROM pedidos
ORDER BY estado
LIMIT 3;

Ábrelo en el editor y cámbialo →

resultado · SQLiteid | estado
---+----------
13 | cancelado
 7 | devuelto
 1 | entregado
resultado · PostgreSQL id |  estado   
----+-----------
 13 | cancelado
  7 | devuelto
  3 | entregado
resultado · MySQL+----+-----------+
| id | estado    |
+----+-----------+
| 13 | cancelado |
|  7 | devuelto  |
|  2 | entregado |
+----+-----------+

Hay 16 pedidos «entregado», y cada motor elige uno distinto como tercero: el 1, el 3 y el 2. Con paginación, eso significa pedidos que salen dos veces y otros que no salen nunca. La solución es terminar el ORDER BY con una columna única, normalmente el id: ORDER BY fecha DESC, id DESC.

Paginación por cursor

En vez de «sáltate N filas», se pide «las 5 siguientes a la última que vi». La última fila de la página 1 era el pedido 18, del 2026-08-21:

-- Página 2 por cursor: «los 5 siguientes al último que vi» (fecha 2026-08-21, id 18)
SELECT id, fecha, estado FROM pedidos
WHERE (fecha, id) < ('2026-08-21', 18)
ORDER BY fecha DESC, id DESC
LIMIT 5;

Ábrelo en el editor y cámbialo →

resultado · SQLiteid | fecha      | estado
---+------------+----------
17 | 2026-08-05 | entregado
16 | 2026-07-14 | entregado
15 | 2026-07-01 | entregado
14 | 2026-06-17 | entregado
13 | 2026-06-02 | cancelado

Es la misma página 2 que con OFFSET 5. La comparación (fecha, id) < ('2026-08-21', 18) compara la pareja en orden: primero la fecha y, si es igual, el id. La admiten SQLite (desde la 3.15), PostgreSQL y MySQL, y los tres devuelven las mismas filas.

Por qué OFFSET se vuelve lento

Para saltarse 900,000 filas, la base de datos tiene que recorrerlas: no puede adivinar dónde empieza la fila 900,001. El cursor, con un índice sobre (creado, id), salta directamente al punto. Lo medimos en SQLite con una tabla de un millón de eventos (mediana de siete ejecuciones):

"""Un millón de eventos en SQLite: página lejana con OFFSET frente a cursor. Mediana de 7 ejecuciones."""
import sqlite3, statistics, time

con = sqlite3.connect(':memory:')
con.executescript("""
CREATE TABLE eventos (id INTEGER PRIMARY KEY, creado TEXT NOT NULL, tipo TEXT NOT NULL);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 1000000)
INSERT INTO eventos (id, creado, tipo)
SELECT i, datetime('2026-01-01', '+' || (i * 30) || ' seconds'), 'visita' FROM n;
CREATE INDEX eventos_creado ON eventos (creado, id);
""")
print('filas:', con.execute('SELECT COUNT(*) FROM eventos').fetchone()[0])

OFFSET = """SELECT id, creado FROM eventos ORDER BY creado DESC, id DESC LIMIT 20 OFFSET 900000"""
CURSOR = """SELECT id, creado FROM eventos WHERE (creado, id) < (?, ?) ORDER BY creado DESC, id DESC LIMIT 20"""

ultima = con.execute("SELECT creado, id FROM eventos ORDER BY creado DESC, id DESC LIMIT 1 OFFSET 899999").fetchone()

def medir(sql, args=()):
    t = []
    for _ in range(7):
        a = time.perf_counter(); filas = con.execute(sql, args).fetchall(); t.append(time.perf_counter() - a)
    return statistics.median(t) * 1000, filas

ms_o, f_o = medir(OFFSET)
ms_c, f_c = medir(CURSOR, ultima)
print('mismas filas:', f_o == f_c, '| primera:', f_o[0])
print(f'OFFSET 900000: {ms_o:.2f} ms')
print(f'cursor:        {ms_c:.3f} ms')
for nombre, sql, args in (('OFFSET', OFFSET, ()), ('cursor', CURSOR, ultima)):
    print(f'plan {nombre}:', ' / '.join(r[3] for r in con.execute('EXPLAIN QUERY PLAN ' + sql, args)))
salida · python medir.pyfilas: 1000000
mismas filas: True | primera: (100000, '2026-02-04 17:20:00')
OFFSET 900000: 6.16 ms
cursor:        0.009 ms
plan OFFSET: SCAN eventos USING COVERING INDEX eventos_creado
plan cursor: SEARCH eventos USING COVERING INDEX eventos_creado (creado<?)

Con OFFSET la base lee y tira las filas anteriores; con un cursor salta directamentePágina 45,001 de un millón de eventos: OFFSET frente a cursorLIMIT 20 OFFSET 900000recorre el índice y descarta 900,000 filas20WHERE (creado, id) < (?, ?) … LIMIT 20salta con el índice al último valor visto20El coste de OFFSET crece con el número de página; el del cursor, casi no

Las dos consultas devuelven las mismas 20 filas; la diferencia está en cuántas lee antes. Esquema: decodigo.com.

Las mismas 20 filas: 6.16 milisegundos con OFFSET y 0.009 con el cursor. El plan lo explica: con OFFSET, SCAN (recorre el índice desde el principio); con el cursor, SEARCH (busca el punto en el índice). Los tiempos cambian de una máquina a otra; la proporción, no tanto, y crece con el número de página.

OFFSETCursor
Ir a la página 37 directamenteSíNo: solo «siguiente» y «anterior»
Coste de una página lejanaCrece con el número de páginaCasi constante
Si se insertan filas mientras se paginaFilas repetidas o saltadasSigue desde donde estaba
Uso típicoListados con números de páginaScroll infinito, APIs, exportaciones

Todas las consultas de esta página se ejecutaron antes de publicarla sobre la base de ejemplo «tienda» del curso, en contenedores limpios: SQLite 3.46.1 (el de Python 3.13), PostgreSQL 17 y MySQL 8.4. Los resultados y los mensajes de error están copiados de esa ejecución. Documentación oficial: valores de fila (row values) en SQLite.