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.

Por Leonardo Gaertner · Publicado em

Os dois problemas do OFFSET

  • Lentidão: OFFSET 100000 obriga 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.

JavaScript
// 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.

Python
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
<?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": "..." }, e null quando 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 COUNT em 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.