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,
};
}import base64
import json
import psycopg
def codificar(cursor: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(cursor).encode()).decode()
def decodificar(texto: str) -> dict:
return json.loads(base64.urlsafe_b64decode(texto))
def listar_pedidos(conexao: psycopg.Connection, limite: int = 20, cursor: str | None = None) -> dict:
limite = min(max(limite, 1), 100)
apos = decodificar(cursor) if cursor else {"criadoEm": None, "id": None}
linhas = conexao.execute(
"""SELECT id, criado_em::text, total
FROM pedidos
WHERE %(c)s::timestamptz IS NULL
OR (criado_em, id) < (%(c)s::timestamptz, %(i)s::bigint)
ORDER BY criado_em DESC, id DESC
LIMIT %(l)s""",
{"c": apos["criadoEm"], "i": apos["id"], "l": limite + 1},
).fetchall()
tem_mais = len(linhas) > limite
itens = linhas[:limite]
proximo = codificar({"criadoEm": itens[-1][1], "id": itens[-1][0]}) if tem_mais else None
return {
"itens": [{"id": str(i), "criadoEm": c, "total": t} for i, c, t in itens],
"proximoCursor": proximo,
}package pedidos
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
)
type Cursor struct {
CriadoEm string `json:"criadoEm"`
ID int64 `json:"id"`
}
type Pedido struct {
ID int64 `json:"id"`
CriadoEm string `json:"criadoEm"`
Total int `json:"total"`
}
func codificar(c Cursor) string {
dados, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(dados)
}
func decodificar(texto string) (*Cursor, error) {
dados, err := base64.RawURLEncoding.DecodeString(texto)
if err != nil {
return nil, err
}
var c Cursor
return &c, json.Unmarshal(dados, &c)
}
func ListarPedidos(ctx context.Context, db *sql.DB, limite int, cursor string) ([]Pedido, string, error) {
limite = min(max(limite, 1), 100)
var criadoEm, id any // nulos na primeira página
if cursor != "" {
c, err := decodificar(cursor)
if err != nil {
return nil, "", err // cursor inválido: responda 400
}
criadoEm, id = c.CriadoEm, c.ID
}
linhas, err := db.QueryContext(ctx,
`SELECT id, criado_em::text, 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`,
criadoEm, id, limite+1)
if err != nil {
return nil, "", err
}
defer linhas.Close()
var itens []Pedido
for linhas.Next() {
var p Pedido
if err := linhas.Scan(&p.ID, &p.CriadoEm, &p.Total); err != nil {
return nil, "", err
}
itens = append(itens, p)
}
proximo := ""
if len(itens) > limite {
itens = itens[:limite]
ultimo := itens[len(itens)-1]
proximo = codificar(Cursor{CriadoEm: ultimo.CriadoEm, ID: ultimo.ID})
}
return itens, proximo, linhas.Err()
}import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class ListaDePedidos {
public record Pedido(long id, String criadoEm, int total) {}
public record Pagina(List<Pedido> itens, String proximoCursor) {}
// Cursor opaco: "criadoEm|id" em base64
static String codificar(String criadoEm, long id) {
var texto = criadoEm + "|" + id;
return Base64.getUrlEncoder().withoutPadding().encodeToString(texto.getBytes(StandardCharsets.UTF_8));
}
public static Pagina listar(Connection conexao, int limiteSolicitado, String cursor) throws SQLException {
int limite = Math.min(Math.max(limiteSolicitado, 1), 100);
String criadoEm = null;
Long id = null;
if (cursor != null) {
var partes = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\\|");
criadoEm = partes[0];
id = Long.parseLong(partes[1]);
}
var sql = """
SELECT id, criado_em::text, total
FROM pedidos
WHERE ?::timestamptz IS NULL OR (criado_em, id) < (?::timestamptz, ?::bigint)
ORDER BY criado_em DESC, id DESC
LIMIT ?""";
try (var consulta = conexao.prepareStatement(sql)) {
consulta.setString(1, criadoEm);
consulta.setString(2, criadoEm);
if (id == null) consulta.setNull(3, Types.BIGINT); else consulta.setLong(3, id);
consulta.setInt(4, limite + 1);
var itens = new ArrayList<Pedido>();
try (var linhas = consulta.executeQuery()) {
while (linhas.next()) {
itens.add(new Pedido(linhas.getLong(1), linhas.getString(2), linhas.getInt(3)));
}
}
if (itens.size() <= limite) return new Pagina(itens, null);
var pagina = itens.subList(0, limite);
var ultimo = pagina.get(limite - 1);
return new Pagina(pagina, codificar(ultimo.criadoEm(), ultimo.id()));
}
}
}using System.Text;
using System.Text.Json;
using Npgsql;
public record Pedido(long Id, string CriadoEm, int Total);
public record Pagina(List<Pedido> Itens, string? ProximoCursor);
record Cursor(string CriadoEm, long Id);
public class ListaDePedidos(NpgsqlDataSource dados)
{
static string Codificar(Cursor c) =>
Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(c)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static Cursor Decodificar(string texto)
{
var base64 = texto.Replace('-', '+').Replace('_', '/');
base64 = base64.PadRight(base64.Length + (4 - base64.Length % 4) % 4, '=');
return JsonSerializer.Deserialize<Cursor>(Convert.FromBase64String(base64))!;
}
public async Task<Pagina> Listar(int limiteSolicitado = 20, string? cursor = null)
{
var limite = Math.Clamp(limiteSolicitado, 1, 100);
var apos = cursor is null ? null : Decodificar(cursor);
await using var comando = dados.CreateCommand("""
SELECT id, criado_em::text, 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
""");
comando.Parameters.Add(new() { Value = (object?)apos?.CriadoEm ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text });
comando.Parameters.Add(new() { Value = (object?)apos?.Id ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Bigint });
comando.Parameters.Add(new() { Value = limite + 1 });
var itens = new List<Pedido>();
await using var linhas = await comando.ExecuteReaderAsync();
while (await linhas.ReadAsync())
itens.Add(new Pedido(linhas.GetInt64(0), linhas.GetString(1), linhas.GetInt32(2)));
if (itens.Count <= limite) return new Pagina(itens, null);
itens.RemoveAt(limite);
var ultimo = itens[^1];
return new Pagina(itens, Codificar(new Cursor(ultimo.CriadoEm, ultimo.Id)));
}
}O resultado, nas cinco linguagens
| Versão | Pedidos vistos | Repetidos | Páginas |
|---|---|---|---|
TypeScript (pg) | 1.000 de 1.000 | 0 | 125 |
Python (psycopg) | 1.000 de 1.000 | 0 | 125 |
Go (database/sql com pgx) | 1.000 de 1.000 | 0 | 125 |
| Java (JDBC) | 1.000 de 1.000 | 0 | 125 |
| C# (Npgsql) | 1.000 de 1.000 | 0 | 125 |
TypeScript com o horário lido como Date | 904 de 1.000 | 0 | 114 |
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 banco | Risco para o cursor | |
|---|---|---|
| TypeScript | Date, com milissegundos | Perde os microssegundos: leia como texto (criado_em::text) |
| Python | datetime, com microssegundos | Nenhum com psycopg; o exemplo usa texto por uniformidade |
| Go | time.Time, com nanossegundos | Nenhum na leitura; cuidado ao formatar sem os segundos fracionários |
| Java | OffsetDateTime, com nanossegundos | Nenhum com getObject; java.util.Date perderia precisão |
| C# | DateTime, com 100 nanossegundos | Nenhum 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=eyJjcmlhZG9FbSI6IjIwMjYtMDMtMTAgMTU6NDI6MDcuODExMjAzKzAwIiwiaWQiOiIxMDAwIn0Quando 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 BYnunca é 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]