# 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.

- Endereço oficial: https://pradev.com.br/guias/paginacao-offset-ou-cursor
- Tipo: Guia
- Publicado em: 2026-10-01
- Atualizado em: 2026-10-01
- Autor: Leonardo Gaertner (https://pradev.com.br/sobre)

## 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 };
}
```

_JavaScript_

## 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
```

_Python_

```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];
}
```

_PHP_

## 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](https://pradev.com.br/guias/base64-com-acentos-utf8).
- 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.

## 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.

## Fontes

- [Use The Index, Luke: paginação sem OFFSET](https://use-the-index-luke.com/no-offset)
- [SQLite: comparação de valores de linha](https://www.sqlite.org/rowvalue.html)

## Guias relacionados

- [Como repetir requisições com backoff exponencial (retry)](https://pradev.com.br/guias/como-repetir-requisicoes-com-backoff): 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 erro](https://pradev.com.br/guias/base64-com-acentos-utf8): Por 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.
