17 de maio de 2026•9 min de leitura
Pare de tratar ordenação como detalhe em APIs REST
Ordenação parece apenas ``sort=price&order=asc``, mas pode causar registros duplicados, itens sumindo, paginação inconsistente, consultas lentas e comportamento imprevisível em produção.
Beleza, mas o que é ordenação?
Ordenação é a forma de definir em que ordem os registros serão retornados.
Exemplo:
GET /products?sort=price&order=ascSignifica:
Buscar produtos ordenados pelo preço em ordem crescente.
1. Paginação sem ordenação confiável é uma fonte clássica de bug'
Porque sem ordenação, a API pode retornar os dados de forma imprevisível.
Exemplo:
GET /products?page=1&limit=10Se não tiver ORDER BY, o banco não garante que os mesmos registros vão aparecer sempre na mesma ordem.
Isso pode gerar bugs como:
- item aparecendo em páginas diferentes
- item duplicado entre páginas
- item sumindo da listagem
- paginação inconsistente
- experiência ruim no frontend
Por isso, uma regra importante:
Toda listagem paginada deveria ter uma ordenação explícita.
Imagine esta lista:
1. Produto A
2. Produto B
3. Produto C
4. Produto DSe você pagina sem ordenação:
GET /products?page=1&limit=2O banco pode retornar:
Produto A, Produto BDepois:
GET /products?page=2&limit=2Você espera:
Produto C, Produto DMas se a ordem interna mudar, você pode receber algo inconsistente.
Por isso:
Paginação sem ordenação confiável é uma fonte clássica de bug.
2. O bug invisível de ordenar só por createdAt
Esse ponto é muito importante.
Imagine que vários produtos têm o mesmo createdAt.
Produto A - 2026-01-01
Produto B - 2026-01-01
Produto C - 2026-01-01Se você ordenar só por createdAt, o banco fica livre para ordenar como ele quiser, exemplo:
LIMIT 2 OFFSET 0Página 1:
Produto A - 2026-01-01
Produto B - 2026-01-01Agora próxima request:
LIMIT 2 OFFSET 2Agora página 2 vira:
Produto A - 2026-01-01Resultado:
- item duplicado
- item sumindo
- paginação inconsistente
Como resolver
Você adiciona um critério de desempate:
ORDER BY created_at DESC, id DESCAgora:
- Ordena por
created_at - Se empatar → ordena por
id
Esse Id normalmente é o ID do registro no banco, ele é único.
Agora a ordenação fica determinística.
Ou seja, mesma query = mesma ordem sempre.
Regra prática MUITO boa
Sempre que ordenar por campo que pode repetir:
createdAt
updatedAt
price
name
statusadicione o id como desempate.
Como fica na API:
GET /products?sort=createdAt&order=descMas internamente o backend aplica:
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' },
]No SQL:
ORDER BY created_at DESC, id DESC3. Ordenação com cursor pagination
Esse é um ponto avançado e importante.
OFFSET pagination (tradicional)
Você conhece isso:
LIMIT 10 OFFSET 20Significa: pule 20 registros e pegue os próximos 10.
O problema disso
Imagine um feed de pedidos:
ord_10 - 10:00
ord_9 - 09:59
ord_8 - 09:58Usuário abre a primeira página:
LIMIT 2 OFFSET 0e recebe:
- ord_10
- ord_9
Agora entra um novo registro no topo do feed:
ord_11 - 10:01
ord_10 - 10:00
ord_9 - 09:59
ord_8 - 09:58Quando o usuário pede a segunda página:
LIMIT 2 OFFSET 2o banco pula os dois primeiros registros da lista atual (ord_11 e ord_10) e entrega:
- ord_9
- ord_8
O problema é que o ord_9 já tinha aparecido na página anterior.
Isso acontece porque OFFSET depende da posição física/lógica dos registros naquele momento. Como a tabela mudou entre uma request e outra, a “página 2” deixou de apontar para o mesmo pedaço da lista.
O cursor pagination resolve isso
Em vez de dizer: “me dê página 2”, você diz: “continue depois deste item”
Exemplo do cursor
{ "nextCursor": "2026-01-09T10:00:00Z|ord_9" }Isso significa:
- o último item da página atual foi o
ord_9 - o
createdAtdele é2026-01-09T10:00:00Z - o
iddele éord_9
Na próxima query você não usa OFFSET, você usa:
WHERE created_at < '2026-01-09T10:00:00Z'Ou seja: “me dê itens mais antigos que o último item que eu já entreguei”.
Mas aí vem o problema do empate
Imagine esta ordenação:
- ord_10 - 10:00
- ord_9 - 10:00
- ord_8 - 10:00
Todos têm a mesma data. Se você fizer só:
created_at < Xvocê perde itens empatados. O filtro busca apenas registros com data menor que 10:00, então o ord_8 fica de fora mesmo devendo aparecer na próxima página.
Então entra o id como desempate
WHERE created_at < '2026-01-09T10:00:00Z'
OR (created_at = '2026-01-09T10:00:00Z'
AND id < 'ord_9')Ou seja:
- registros mais antigos
- ou, se a data empatar, ids menores que
ord_9
Isso cria uma posição exata
Cursor:
createdAt + idvira literalmente um marcador único da posição no feed.
Visualmente
Ordenação:
10:00 | ord_10
10:00 | ord_9 ← cursor atual
10:00 | ord_8
09:59 | ord_7Próxima página: pega tudo depois do ord_9.
Resultado:
10:00 | ord_8
09:59 | ord_7Isso é robusto porque não depende de posição da linha, depende do valor ordenado. Sendo assim, se novos registros entrarem, não quebram a paginação.
Outra vantagem gigante: performance
OFFSET 100000O banco precisa: ler e descartar 100 mil linhas
Já com o cursor:
WHERE created_at < X, id Xmuito mais eficiente com índice.
CREATE INDEX idx_orders_created_idON orders(created_at DESC, id DESC);Quando cursor pagination brilha
- feeds
- timeline
- chat
- infinite scroll
- redes sociais
- logs
- grandes volumes
Quando OFFSET ainda é ok
- admin panel pequeno
- poucos dados
- tabelas pequenas
- relatórios simples
O ponto principal é:
Cursor pagination não pensa em “página 2, página 3”. Ela pensa em: “continue a partir deste item”.
4. Nunca aceite qualquer campo de ordenação
Esse é um erro muito comum, pois abre espaço para injeção ou comportamento perigoso.
Não faça isso sem validação:
const sort = req.query.sort;e depois jogar direto no banco. Você deve permitir apenas campos conhecidos.
Exemplo:
const allowedSortFields = ['name', 'price', 'createdAt'] as const;Se o usuário mandar:
GET /products?sort=passwordou:
GET /products?sort=someInternalFieldo backend deve rejeitar com:
400 Bad Request{ "error": "Invalid sort field",
"allowedFields": ["name", "price", "createdAt"]
}Ou se ele mandar:
GET /products?order=upResposta:
{ "error": "Invalid sort order",
"allowedOrders": ["asc", "desc"]
}5. Validações
Manual
const allowedSortFields = ['name', 'price', 'createdAt'] as const;
const allowedOrders = ['asc', 'desc'] as const;
app.get('/products', async (req, res) => {
const sort = String(req.query.sort ?? 'createdAt');
const order = String(req.query.order ?? 'desc');
if (!allowedSortFields.includes(sort as any)) {
return res.status(400).json({
error: `Invalid sort field: ${sort}`,
});
}
if (!allowedOrders.includes(order as any)) {
return res.status(400).json({
error: `Invalid order: ${order}`,
});
}
const products = await db.product.findMany({
orderBy: [
{ [sort]: order },
{ id: order },
],
});
res.json({ data: products });
});Aqui melhoramos bastante:
- validamos o campo
- validamos a direção
- adicionamos
idcomo desempate
Exemplo com Zod
import { z } from 'zod';
const productQuerySchema = z.object({
sort: z.enum(['name', 'price', 'createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
});
app.get('/products', async (req, res) => {
const parsed = productQuerySchema.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({
error: 'Invalid query params',
details: parsed.error.flatten(),
});
}
const { sort, order, page, limit } = parsed.data;
const skip = (page - 1) * limit;
const [products, total] = await Promise.all([
db.product.findMany({
skip,
take: limit,
orderBy: [
{ [sort]: order },
{ id: order },
],
}),
db.product.count(),
]);
res.json({
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
sorting: {
sort,
order,
},
});
});
6. Ordenação e performance
Ordenar pode ser caro.
Exemplo:
SELECT *FROM productsORDER BY price ASC;Se a tabela for pequena, ok. Se tiver milhões de registros, talvez precise de índice:
CREATE INDEX idx_products_price ON products(price);Índices para ordenação
Se você ordena muito produtos por:
ORDER BY created_at DESCpode fazer sentido ter índice:
CREATE INDEX idx_products_created_at ON products(created_at);Se você filtra e ordena:
WHERE tenant_id = ?
AND status = ?
ORDER BY created_at DESCtalvez faça sentido índice composto:
CREATE INDEX idx_orders_tenant_status_created_at
ON orders(tenant_id, status, created_at);Esse ponto é importante em sistemas multi-tenant porque o trenant quase sempre vai no select, a ordenação precisa ser pensada junto com o filtro obrigatório de tenant.
7. Ordenação com campos calculados
Às vezes o cliente quer ordenar por algo que não é uma coluna simples.
Exemplo:
GET /products?sort=discountPercentage&order=descMas discountPercentage talvez seja calculado assim:
(originalPrice - currentPrice) / originalPriceIsso pode ser mais caro.
Você pode resolver de algumas formas:
Calcular no banco
ORDER BY ((original_price - current_price) / original_price) DESCSalvar campo calculado
discount_percentageAtualizado quando preço muda.
Não permitir essa ordenação
Às vezes a melhor decisão é dizer: Essa API não suporta ordenação por esse campo.
8 Ordenação por múltiplos campos
Às vezes você quer ordenar por mais de um campo.
Exemplo:
GET /products?sort=category,name&order=asc,ascSignifica:
ordenar por categoria e, dentro da categoria, por nome.
Mas esse padrão pode ficar meio confuso.
Outro padrão comum:
GET /products?sort=category:asc,name:ascOu:
GET /products?sort=category,-createdAtNesse último padrão:
category = asc
-createdAt = desc9. Quando ordenar no frontend é aceitável?
Pode ser aceitável quando:
- você já tem todos os dados carregados
- lista é pequena
- é apenas ordenação visual
- não há paginação real no backend
Exemplo: lista de 20 itens em memória
Ok ordenar no frontend. Mas para tabelas grandes, dados paginados ou dados vindos do banco, ordene no backend.
Sempre que você implementar ordenação, pergunte:
1. Qual é a ordenação padrão?
2. Quais campos o cliente pode ordenar?
3. O order aceita só asc/desc?
4. Tem critério de desempate?
5. Isso combina com paginação?
6. Existe índice para essa ordenação?
7. O campo é seguro para expor?
8. A resposta documenta a ordenação aplicada?Resumo central
Ordenação não é só colocar ORDER BY. Ordenação bem feita envolve:
- query params claros
- valor padrão
- validação
- whitelist de campos
- integração com paginação
- critério de desempate
- performance com índices
- segurança
- documentação