Paginação com offset ou cursor: qual usar e como fazer
A paginação com LIMIT e OFFSET é a mais simples, mas fica lenta nas páginas finais e repete ou pula itens quando novos registros chegam. A paginação por cursor continua a partir do último item visto e não tem esses problemas. Este guia compara as duas, mostra o SQL de cada uma e traz exemplos em JavaScript, Python e PHP testados com SQLite.
Os dois problemas do OFFSET
- Lentidão:
OFFSET 100000obriga o banco a ler e descartar 100 mil linhas antes de devolver as próximas. Quanto mais funda a página, mais lenta. - Itens repetidos ou pulados: se um registro novo entra entre a primeira e a segunda página, tudo anda uma posição, e o último item da página 1 aparece de novo na página 2. Se um item é apagado, outro é pulado.
Para telas de administração com números de página e poucos dados, o OFFSET resolve. Para feeds, rolagem infinita, APIs e tabelas grandes, use cursor.
Como funciona a paginação por cursor
Em vez de pedir "a página 2", o cliente pede "os próximos depois do item X". O SQL vira WHERE id < :ultimo ORDER BY id DESC LIMIT 20, e o banco usa o índice para ir direto ao ponto, na mesma velocidade em qualquer página. Pedir um item a mais que o tamanho da página mostra se existe uma próxima, sem precisar de um COUNT.
// Exemplos com o node:sqlite (Node.js 22.5+). Com outro banco, muda só a chamada.
// Offset: simples, mas o banco lê e descarta todas as linhas das páginas anteriores
function paginaPorOffset(db, pagina, tamanho = 20) {
return db
.prepare('SELECT id, titulo FROM posts ORDER BY id DESC LIMIT ? OFFSET ?')
.all(tamanho, (pagina - 1) * tamanho);
}
// Cursor: continua depois do último item visto, usando o índice da coluna
function paginaPorCursor(db, depoisDe = null, tamanho = 20) {
const linhas =
depoisDe === null
? db.prepare('SELECT id, titulo FROM posts ORDER BY id DESC LIMIT ?').all(tamanho + 1)
: db
.prepare('SELECT id, titulo FROM posts WHERE id < ? ORDER BY id DESC LIMIT ?')
.all(depoisDe, tamanho + 1);
// Pede um a mais só para saber se existe próxima página
const itens = linhas.slice(0, tamanho);
return { itens, proximo: linhas.length > tamanho ? itens.at(-1).id : null };
} Ordenando por data: desempate pelo id
Quando a ordem é por uma coluna que se repete, como a data de criação, o cursor precisa de um desempate, senão itens com a mesma data somem entre as páginas. Use o par (data, id) e a comparação de linha (criado_em, id) < (?, ?), que funciona no SQLite, no PostgreSQL e no MySQL 8. Crie um índice com as duas colunas, na mesma ordem.
import sqlite3
def pagina_por_cursor(conn: sqlite3.Connection, depois_de=None, tamanho: int = 20):
"""Ordena por data e desempata pelo id. depois_de: (criado_em, id) do último item visto."""
if depois_de is None:
sql = "SELECT id, titulo, criado_em FROM posts ORDER BY criado_em DESC, id DESC LIMIT ?"
args = (tamanho + 1,)
else:
# Comparação de linha: (criado_em, id) < (?, ?) vale em SQLite, PostgreSQL e MySQL 8
sql = (
"SELECT id, titulo, criado_em FROM posts WHERE (criado_em, id) < (?, ?) "
"ORDER BY criado_em DESC, id DESC LIMIT ?"
)
args = (*depois_de, tamanho + 1)
linhas = conn.execute(sql, args).fetchall()
itens = linhas[:tamanho]
proximo = (itens[-1][2], itens[-1][0]) if len(linhas) > tamanho else None
return itens, proximo <?php
function paginaPorCursor(PDO $db, ?int $depoisDe = null, int $tamanho = 20): array
{
$sql = 'SELECT id, titulo FROM posts '
. ($depoisDe === null ? '' : 'WHERE id < :depois ')
. 'ORDER BY id DESC LIMIT :limite';
$stmt = $db->prepare($sql);
if ($depoisDe !== null) {
$stmt->bindValue(':depois', $depoisDe, PDO::PARAM_INT);
}
// Um a mais, só para saber se existe próxima página
$stmt->bindValue(':limite', $tamanho + 1, PDO::PARAM_INT);
$stmt->execute();
$linhas = $stmt->fetchAll(PDO::FETCH_ASSOC);
$itens = array_slice($linhas, 0, $tamanho);
$proximo = count($linhas) > $tamanho ? end($itens)['id'] : null;
return ['itens' => $itens, 'proximo' => $proximo];
} Na API
- Devolva o cursor da próxima página junto com os itens, por exemplo
{ "itens": [...], "proximo": "..." }, enullquando acabar. - Use um cursor opaco: codifique os valores (como
{"criado_em": ..., "id": ...}) em Base64URL, para o cliente não depender do formato. Veja o guia de Base64. - O cursor não permite pular direto para a página 50. Se a tela precisa disso, mantenha o OFFSET nessa tela.
- Evite devolver o total de registros em toda página: o
COUNTem tabelas grandes custa caro. "Há mais" costuma bastar.
Achou um erro neste guia? Avise por e-mail.
Perguntas frequentes
Por que a paginação com OFFSET fica lenta?
Porque o banco precisa ler e descartar todas as linhas antes do OFFSET para chegar às que você pediu. Na página 5.000, isso pode ser centenas de milhares de linhas lidas a cada requisição.
O que é paginação por cursor (keyset)?
É pedir os itens que vêm depois do último item já visto, com um WHERE na coluna ordenada, em vez de pular um número de linhas. O banco usa o índice e responde rápido em qualquer página.
Por que aparecem itens repetidos ao paginar?
Com OFFSET, um registro novo inserido entre duas requisições empurra os outros uma posição, e o último item de uma página volta na seguinte. A paginação por cursor não tem esse problema.
Dá para ir direto para uma página com cursor?
Não. O cursor só sabe continuar a partir de um item. Para telas com números de página, o OFFSET ainda é a opção prática, de preferência com tabelas pequenas.
Outros guias
- Como repetir requisições com backoff exponencial (retry)Como repetir chamadas de API que falham com backoff exponencial e jitter, quais erros vale repetir e como não cobrar duas vezes, em JavaScript, Python e PHP.
- Base64 com acentos: como codificar e decodificar sem erroPor que o btoa erra com acentos e emojis e como codificar e decodificar Base64 em UTF-8 e Base64URL em JavaScript, Python e PHP, com código testado.