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.
También te puede interesar
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<?)
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.
| OFFSET | Cursor | |
|---|---|---|
| Ir a la página 37 directamente | Sí | No: solo «siguiente» y «anterior» |
| Coste de una página lejana | Crece con el número de página | Casi constante |
| Si se insertan filas mientras se pagina | Filas repetidas o saltadas | Sigue desde donde estaba |
| Uso típico | Listados con números de página | Scroll 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.