DevGuideTK
← Trilha Backend

Paginação, filtros e ordenação

Paginação por deslocamento ou por cursor, e por que a primeira degrada com tabelas grandes e repete itens quando chegam dados novos.

Os exemplos implementam a paginação por cursor sobre a tabela de pedidos, com o índice em (criado_em DESC, id DESC). Cada versão foi executada contra um PostgreSQL 17 com 1.000 pedidos (900 com horários aleatórios, com microssegundos, e 100 criados no mesmo instante), percorrendo todas as páginas de 8 em 8.

CREATE INDEX ON pedidos (criado_em DESC, id DESC);

Paginação por cursor, em cada linguagem

import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

type Cursor = { criadoEm: string; id: string };

// Cursor opaco: o cliente devolve sem interpretar
const codificar = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString("base64url");
const decodificar = (texto: string): Cursor => JSON.parse(Buffer.from(texto, "base64url").toString());

export async function listarPedidos(limiteSolicitado = 20, cursor?: string) {
  const limite = Math.min(Math.max(limiteSolicitado, 1), 100);
  const apos = cursor ? decodificar(cursor) : null;

  // criado_em::text preserva os microssegundos; o Date do JavaScript não
  const { rows } = await pool.query(
    `SELECT id, criado_em::text AS criado_em, total
       FROM pedidos
      WHERE $1::timestamptz IS NULL OR (criado_em, id) < ($1::timestamptz, $2::bigint)
      ORDER BY criado_em DESC, id DESC
      LIMIT $3`,
    [apos?.criadoEm ?? null, apos?.id ?? null, limite + 1],
  );

  const temMais = rows.length > limite;
  const itens = rows.slice(0, limite);
  const ultimo = itens.at(-1);
  return {
    itens,
    proximoCursor: temMais && ultimo
      ? codificar({ criadoEm: ultimo.criado_em, id: String(ultimo.id) })
      : null,
  };
}

O resultado, nas cinco linguagens

VersãoPedidos vistosRepetidosPáginas
TypeScript (pg)1.000 de 1.0000125
Python (psycopg)1.000 de 1.0000125
Go (database/sql com pgx)1.000 de 1.0000125
Java (JDBC)1.000 de 1.0000125
C# (Npgsql)1.000 de 1.0000125
TypeScript com o horário lido como Date904 de 1.0000114

A última linha é a versão do nível Conceito que perdia precisão. Os 96 pedidos que faltaram estavam entre os 100 criados no mesmo instante.

O que muda de uma linguagem para outra

Horário lido do bancoRisco para o cursor
TypeScriptDate, com milissegundosPerde os microssegundos: leia como texto (criado_em::text)
Pythondatetime, com microssegundosNenhum com psycopg; o exemplo usa texto por uniformidade
Gotime.Time, com nanossegundosNenhum na leitura; cuidado ao formatar sem os segundos fracionários
JavaOffsetDateTime, com nanossegundosNenhum com getObject; java.util.Date perderia precisão
C#DateTime, com 100 nanossegundosNenhum na leitura; cuidado ao formatar

Todas as versões leem o horário como texto do próprio PostgreSQL e o devolvem ao banco na consulta seguinte, sem conversão. Assim a comparação (criado_em, id) < (...) usa exatamente o valor que o banco guardou, em qualquer linguagem.

Os exemplos não tratam cursores malformados, para ficarem curtos. Em produção, envolva a decodificação num tratamento de erro que responda 400, e nunca deixe um valor do cursor chegar à consulta sem passar pela conversão de tipo do banco (::timestamptz, ::bigint), que recusa valores inválidos.

A rota HTTP

GET /pedidos?limite=20
{
  "itens": [
    { "id": "1000", "criadoEm": "2026-03-10 15:42:07.811203+00", "total": 15990 }
  ],
  "proximoCursor": "eyJjcmlhZG9FbSI6IjIwMjYtMDMtMTAgMTU6NDI6MDcuODExMjAzKzAwIiwiaWQiOiIxMDAwIn0"
}
GET /pedidos?limite=20&cursor=eyJjcmlhZG9FbSI6IjIwMjYtMDMtMTAgMTU6NDI6MDcuODExMjAzKzAwIiwiaWQiOiIxMDAwIn0

Quando proximoCursor vem nulo, a lista acabou. O cliente não monta o cursor, não o interpreta e não faz contas com ele: só devolve o que recebeu.

Filtros sem injeção de SQL

A ordenação não pode ser passada como parâmetro da consulta. Montar o ORDER BY com o texto recebido abre uma injeção de SQL. A forma segura é mapear o valor para uma lista fechada:

// Só estas ordenações existem, e cada uma tem um índice correspondente
const ORDENACOES = {
  "-criadoEm": "criado_em DESC, id DESC",
  "criadoEm": "criado_em ASC, id ASC",
  "-total": "total DESC, id DESC",
} as const;

function clausulaDeOrdem(pedida: string | undefined) {
  const ordem = ORDENACOES[(pedida ?? "-criadoEm") as keyof typeof ORDENACOES];
  if (!ordem) throw new ErroDeValidacao("ordem", "valor não permitido");
  return ordem; // texto fixo, nunca o valor do cliente
}

Os filtros, por sua vez, sempre como parâmetros da consulta ($1, %s, ?), como em todos os exemplos da trilha.

Lista de revisão

Paginação

  • Toda listagem tem paginação, com tamanho padrão e máximo?
  • Listas que crescem e APIs de integração usam cursor?
  • A ordenação tem desempate único e um índice que a cobre?
  • O cursor preserva a precisão dos valores, sem conversões da linguagem?
  • Cursores malformados respondem 400?

Filtros e ordenação

  • Filtros e ordenações vêm de uma lista fechada?
  • O ORDER BY nunca é montado com o texto recebido do cliente?
  • Cada filtro e ordenação oferecidos têm índice?

Total

  • O total é calculado só quando necessário?
  • Telas com deslocamento têm limite de profundidade?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar e testar a paginação por cursor

    Quero implementar a paginação por cursor na minha linguagem e provar que ela está correta.
    
    Pergunte qual linguagem, banco e driver eu uso.
    
    Vamos em etapas:
    1. Criar uma tabela de teste com alguns milhares de itens, incluindo muitos com o mesmo horário.
    2. Implementar a listagem com cursor opaco, desempate pelo identificador e tamanho máximo.
    3. Escrever um teste que percorre todas as páginas e confere que cada item aparece exatamente uma vez.
    4. Provocar a perda de precisão de propósito e ver o teste falhar.
    5. Inserir itens no meio da paginação e confirmar que nada se repete.
    6. Medir o tempo de uma página profunda com OFFSET e com cursor.
  • Medir o custo do OFFSET no meu banco

    Quero medir no meu próprio banco quanto a paginação por OFFSET custa em páginas profundas.
    
    Pergunte qual banco eu uso e o tamanho das tabelas.
    
    Me guie para:
    1. Gerar uma tabela de teste com alguns milhões de linhas, ou usar uma cópia de uma tabela real.
    2. Medir com EXPLAIN ANALYZE as páginas nos deslocamentos 0, 10 mil, 100 mil e 1 milhão.
    3. Medir a mesma profundidade com cursor.
    4. Medir o custo de um COUNT com e sem filtros.
    5. Interpretar os planos de execução de cada caso.
  • Revisar uma rota de listagem

    Vou colar abaixo o código de uma rota de listagem da minha API. Revise paginação, filtros, ordenação e total.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando a linha:
    1. Há paginação, com tamanho padrão e máximo?
    2. A forma de paginação é adequada ao tamanho e ao uso da lista?
    3. A ordenação tem desempate único?
    4. O cursor preserva a precisão dos valores?
    5. O ORDER BY é montado de forma segura?
    6. Os filtros são parâmetros da consulta?
    7. Há índices para os filtros e ordenações?
    8. O total é calculado só quando necessário?
    
    Para cada problema, explique a consequência e mostre a correção.
    
    Código:
    [cole aqui]