Todo endpoint de lista eventualmente enfrenta a mesma pergunta: como você divide 2 milhões de pedidos em páginas que um cliente pode percorrer? Escolha a paginação por offset e você terá SQL simples mais números de página que os usuários entendem. Escolha a paginação baseada em cursor e você terá resultados estáveis mais latência consistente em qualquer profundidade, mas abrirá mão de “ir para a página 47.”
A maioria das equipes escolhe o offset porque é o padrão em todo tutorial. Então, a tabela de pedidos atinge alguns milhões de linhas, a página 4.000 começa a expirar, e os usuários relatam ver o mesmo registro duas vezes enquanto rolam. Este guia cobre como ambos os estilos funcionam, onde o offset falha, por que Stripe e Slack usam cursores, e como testar qualquer estilo com requisições encadeadas no Apidog. Ao final, você saberá exatamente qual deles se encaixa no seu endpoint.
Se você quiser o panorama mais amplo primeiro, nosso guia de paginação de API aborda cada estratégia lado a lado. Este artigo aprofunda nas duas que mais importam.
Como funciona a paginação por offset
A paginação por offset mapeia diretamente para SQL. O cliente envia um número de página e um tamanho de página; o servidor os traduz em LIMIT e OFFSET.
SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Essa consulta retorna a página 3 da sua lista de pedidos com 25 linhas por página. A requisição se parece com isto:
GET /v1/orders?page=3&per_page=25
E uma resposta típica:
{
"data": [
{
"id": "ord_8821",
"customer_id": "cus_1932",
"total_cents": 4599,
"created_at": "2026-08-30T14:22:07Z"
}
],
"page": 3,
"per_page": 25,
"total": 1848203,
"total_pages": 73929
}
O apelo é óbvio. Clientes podem pular para qualquer página. O servidor pode retornar uma contagem total. Qualquer desenvolvedor pode construí-lo em uma tarde. Para uma pequena tabela de administração, esta é a escolha certa, e nosso guia passo a passo para paginação em APIs REST mostra uma construção completa por offset.
Mas o offset apresenta dois problemas estruturais, e nenhum deles aparece no desenvolvimento. Ambos aparecem em produção.
Problema 1: desvio de página
O offset conta linhas a partir do topo do resultado ordenado. Ele não sabe nada sobre quais linhas o cliente já viu. Assim, quando linhas são inseridas ou excluídas entre as requisições, as páginas se deslocam sob o cliente.
Digamos que um usuário carrega a página 1 de pedidos ordenados do mais novo para o mais antigo, linhas de 1 a 25. Enquanto ele lê, 3 novos pedidos chegam. Ele requisita a página 2, que é OFFSET 25. As linhas 23, 24 e 25 da primeira resposta foram agora empurradas para as posições 26 a 28. O usuário as vê novamente. Duplicatas.
A exclusão inverte. Remova 3 linhas da página 1 enquanto o usuário a lê, e OFFSET 25 agora pula 3 linhas que o usuário nunca viu. Perda silenciosa de dados, e ninguém recebe um erro.
Para um relatório mensal que ninguém rola em tempo real, o desvio é inofensivo. Para um feed de atividades, um endpoint de sincronização, ou qualquer coisa que um script percorra página por página enquanto as escritas continuam, o desvio significa registros duplicados ou ausentes. Os consumidores percebem.
Problema 2: offsets profundos escaneiam tudo que ignoram
OFFSET 500000 não teletransporta para a linha 500.001. O banco de dados percorre o índice através de meio milhão de entradas, as descarta, e então retorna suas 25 linhas. O custo cresce linearmente com a profundidade: O(n) onde n é o offset.
Números concretos tornam isso real. Em uma tabela de pedidos Postgres com 2 milhões de linhas e um índice em created_at:
LIMIT 25 OFFSET 0lê 25 entradas de índice. Alguns milissegundos.LIMIT 25 OFFSET 100000lê 100.025 entradas e descarta 100.000. Dezenas de milissegundos.LIMIT 25 OFFSET 1500000lê 1,5 milhão de entradas. Agora você está profundamente em centenas de milissegundos, segurando buffers e queimando CPU por uma página.
O artigo sobre "no-offset" de Markus Winand no Use The Index, Luke demonstra este custo com planos de consulta e vale a pena ler na íntegra. O padrão em produção é um log de consultas lentas dominado por requisições de alto offset, frequentemente de um crawler que diligentemente percorre cada página da sua API pública. Um cliente, e seu p99 dobra.
Como funciona a paginação baseada em cursor
A paginação baseada em cursor, também chamada de paginação por keyset, elimina o contador de linhas. Em vez de “pular 50 linhas”, o cliente diz “me dê as linhas após este registro específico.” O cursor identifica a última linha que o cliente viu, para que o servidor possa buscar diretamente o próximo lote.
O SQL usa uma comparação de linhas na chave de ordenação em vez de OFFSET:
SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Observe a comparação de duas colunas. created_at sozinho não é único; dois pedidos podem cair no mesmo milissegundo, e uma chave de ordenação não única significa que as linhas são puladas ou repetidas nos limites da página. Adicionar id como um desempate torna a ordenação total e a paginação exata. Com um índice composto em (created_at, id), o banco de dados busca diretamente o limite e lê 25 entradas. A página 1 e a página 60.000 custam o mesmo.
A API não deveria expor esses valores brutos, no entanto. Implementações reais codificam a chave de ordenação em um token opaco, geralmente base64:
GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Opacidade é uma decisão de design, não ofuscação por si só. Clientes que não conseguem analisar o cursor não podem construir URLs manualmente, o que o deixa livre para mudar a chave de ordenação, adicionar uma dica de shard, ou trocar motores de armazenamento sem quebrar ninguém. O contrato se torna “devolva o que lhe demos,” nada mais.
A contrapartida: não há página 47. Um cursor só sabe “depois desta linha”, então os clientes avançam (e retrocedem, se você emitir um cursor anterior) uma página por vez. Contagens totais também não vêm de graça; a contagem é uma consulta separada. Para designs onde o próprio conjunto de dados é enorme, nosso guia sobre como projetar paginação de API para milhões de registros aborda o lado da escalabilidade com mais profundidade.
Compromissos em um relance
| Dimensão | Paginação por offset | Paginação baseada em cursor |
|---|---|---|
| Pular para página arbitrária | Sim, qualquer número de página | Não, apenas caminhada sequencial |
| Contagem total / contagem de páginas | Barato de incluir | Consulta de contagem separada |
| Desempenho em páginas profundas | O(n), degrada com a profundidade | O(1) por página em qualquer profundidade |
| Estabilidade sob escritas | Desvia: duplicatas e lacunas | Estável, ancorado a uma linha |
| Custo de construção | Trivial | Moderado: codificação, desempates, design de índice |
| Requisitos de ordenação | Qualquer ORDER BY funciona | Precisa de uma chave de ordenação única e indexada |
| Cache de URLs de página | Fácil, URLs são previsíveis | Mais difícil, cursores variam por caminhada |
| Complexidade do cliente | Baixa | Baixa, se o envelope for limpo |
Uma sutileza nessa tabela merece ênfase: a paginação por cursor exige uma ordenação determinística. Se seu endpoint permite que os clientes ordenem por uma coluna mutável e não única como status, a lógica do keyset se torna dolorosa rapidamente. O offset tolera ordenação imprecisa; cursores a punem.
Qual você deve escolher?
Combine o estilo com a forma como os dados são consumidos.
Tabelas administrativas e dashboards: offset. Ferramentas internas com alguns milhares de linhas, humanos clicando em números de página, e uma contagem visível de “1.848 resultados”. O desvio não importa, a profundidade permanece rasa, e "ir para a página" é um recurso real. O offset vence no custo de construção.
Feeds de rolagem infinita: cursor. Ninguém pula para a página 47 de um feed. Os usuários sempre carregam “mais”, as escritas acontecem constantemente, e duplicatas são visíveis e embaraçosas. Este é o caso de cursor de manual.
APIs públicas: cursor. Você não controla seus consumidores. Alguém escreverá um loop percorrendo cada página, e com offset, páginas profundas se tornam seu problema às 3 da manhã. Cursores mantêm cada página barata e permitem que você evolua os internos por trás do token opaco. Nosso guia de paginação de API REST cobre as convenções de URL e cabeçalho em detalhes.
Exportações e jobs de sincronização: cursor. Um job em lote que puxa todos os 2 milhões de pedidos precisa de duas garantias: nenhuma linha perdida apesar de escritas concorrentes, e custo fixo por página. Offset não oferece nenhuma. Um cursor também oferece um ponto de retomada gratuito quando o job morre na linha 1.4 milhão.
A regra de ouro honesta: offset para interfaces pequenas, navegadas por humanos e com muita contagem; cursores para qualquer coisa grande, ao vivo ou pública.
Como APIs reais lidam com isso
Stripe é totalmente baseado em cursor. Todo endpoint de lista aceita starting_after (um ID de objeto) e limit, e as respostas incluem has_more. Para buscar a próxima página de cobranças, você passa o ID da última cobrança que recebeu. A documentação de paginação da Stripe mostra o padrão; note que não há contagem total em lugar nenhum, uma omissão deliberada devido ao volume de escritas deles.
A API REST do GitHub ainda expõe page e per_page na maioria dos endpoints, com cabeçalhos Link apontando para as páginas seguintes e últimas. Mas leia atentamente a documentação de paginação do GitHub: ela instrui os clientes a seguir o cabeçalho Link literalmente em vez de construir URLs de página, e endpoints mais novos mudaram para cursores, exatamente porque caminhadas profundas de offset em repositórios massivos eram problemáticas.
Slack migrou sua Web API para paginação por cursor e agora a marca como a abordagem que todos os novos métodos usam. Métodos como conversations.history retornam response_metadata.next_cursor, e uma string de cursor vazia significa que você chegou ao fim, conforme descrito na documentação de paginação do Slack.
Três APIs de alto tráfego, e a direção da viagem é única: em direção aos cursores.
Projetando o envelope de resposta
Uma API de cursor vive ou morre com seu envelope. Mantenha-o simples e previsível:
{
"data": [
{
"id": "ord_8846",
"customer_id": "cus_2201",
"total_cents": 12900,
"created_at": "2026-08-30T16:01:44Z"
}
],
"has_more": true,
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Quatro regras o tornam sólido:
- Sempre retorne
has_more. Clientes não devem inferir o fim de uma página curta; uma página pode ser curta no meio da transmissão se você filtrar após a busca. - Retorne
next_cursor: nullna página final, e documente isso. A convenção de string vazia do Slack também funciona; escolha uma e nunca as misture. - Rejeite cursores inválidos com um 400, não um 200 vazio. Um cursor ilegível é um bug do cliente, e escondê-lo custa a alguém um dia de depuração.
- Assine ou versiona o payload do cursor se ele codificar algo além das chaves de ordenação. Você agradecerá a si mesmo durante a próxima migração de esquema.
Testando ambos os estilos no Apidog
Bugs de paginação se escondem nas fronteiras: a última página, a página vazia, o cursor cuja linha âncora foi excluída. Clicar manualmente não os pegará, mas um cenário de teste encadeado sim, e é aqui que o Apidog ganha seu lugar no fluxo de trabalho.
Para endpoints de cursor, construa um cenário de teste com duas etapas:
- Chame o endpoint e extraia o cursor. Adicione um pós-processador à primeira requisição com o JSONPath
$.next_cursor, e armazene-o em uma variável comonextCursor. O Apidog permite copiar o JSONPath diretamente do painel de resposta; o passo a passo completo está em como definir asserções e extrair variáveis com JSONPath. - Execute o loop da requisição da próxima página. Embrulhe uma segunda requisição em um passo ForEach ou de loop, passe
{{nextCursor}}como parâmetro do cursor, reextraia$.next_cursora cada iteração, e saia quandohas_morefor falso. Afirme a cada passagem que nenhumidse repete da página anterior e que o tamanho da página nunca excedelimit.
Para endpoints de offset, a mesma estrutura se aplica com uma variável de contador: incremente page, afirme que o comprimento de data é igual a per_page até a página final, e afirme que total permanece consistente ao longo da caminhada.
Em seguida, adicione os casos de borda como suas próprias etapas, cada uma com asserções explícitas:
- Página vazia: solicite um filtro que corresponda a zero linhas; afirme que
dataé[],has_moreé falso e o status é 200. - Cursor inválido: envie
cursor=not-a-real-cursor; afirme status 400 e um código de erro legível por máquina. - Linha âncora excluída: crie um pedido, pegue um cursor ancorado a ele, exclua o pedido e, em seguida, use o cursor; afirme que a caminhada continua da posição correta em vez de gerar um erro. Comparações de keyset lidam com isso naturalmente, e o teste prova isso.
Assim que o cenário passar localmente, execute-o no CI a cada merge. Baixe o Apidog gratuitamente e você pode ter o cenário completo de caminhada por cursor, incluindo loops e asserções, funcionando em menos de meia hora.
FAQ
A paginação por cursor é sempre melhor?
Não. O offset é mais adequado quando os usuários precisam de números de página, totais e acesso aleatório a um conjunto de dados modesto, o que descreve a maioria das ferramentas administrativas internas. Cursores são melhores quando o conjunto de dados é grande, as escritas são frequentes ou a API é pública. O modo de falha é usar offset por padrão para um endpoint de lista pública e descobrir o custo O(n) após o lançamento.
Como obtenho uma contagem total com paginação por cursor?
Execute um SELECT COUNT(*) separado com os mesmos filtros, seja como um endpoint distinto ou um parâmetro de consulta opcional como include_count=true. Armazene-o em cache agressivamente; uma contagem aproximada atualizada a cada minuto satisfaz quase todas as UIs. O Stripe ignora totalmente os totais, o que indica a frequência com que os clientes realmente precisam deles.
Posso oferecer ambos os estilos de paginação em um único endpoint?
Você pode, e o GitHub efetivamente faz isso durante sua transição, mas evite em novas APIs. Dois estilos significam dois conjuntos de casos de borda, duas matrizes de teste e confusão do cliente sobre qual usar. Escolha um por endpoint. Se você estiver projetando o contrato do zero, os padrões em nosso guia de paginação de API REST manterão a nomenclatura dos parâmetros consistente em toda a sua superfície.
O que acontece se a linha âncora do cursor for excluída?
Com a paginação por keyset, nada quebra. A comparação WHERE (created_at, id) < (?, ?) não exige que a linha âncora exista; ela busca a posição limite e continua. Esta é uma vantagem real sobre designs de "cursor como pesquisa de linha", e é exatamente o caso de borda que vale a pena afirmar em seu cenário de teste do Apidog antes que um consumidor o encontre por você.
