A query está lenta. Você olha o WHERE, identifica a coluna, cria o índice. Roda de novo. Continua lenta. Cria outro índice. Nada. Você começa a desconfiar que o Postgres está quebrado.
Ele não está. Na esmagadora maioria das vezes, o Postgres está ignorando o seu índice de propósito — ou porque a sua query está escrita de um jeito que impede o uso dele, ou porque usá-lo seria de fato mais lento. O banco quase sempre tem razão. O trabalho é descobrir qual dos dois casos é o seu.
Pare de adivinhar: EXPLAIN ANALYZE é a verdade
Toda otimização de query que começa no chute termina em mais chute. O ponto de partida é sempre o mesmo comando:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM pedidos WHERE cliente_id = 42;
O EXPLAIN sozinho mostra o plano estimado. Com ANALYZE, o Postgres executa de verdade e mostra os números reais. Com BUFFERS, mostra quanta página de disco/memória foi lida. A diferença entre estimado e real é onde mora quase todo problema.
Seq Scan on pedidos (cost=0.00..18334.00 rows=1 width=64)
(actual time=0.412..92.318 rows=1 loops=1)
Filter: (cliente_id = 42)
Rows Removed by Filter: 1999999
Buffers: shared hit=8334
Planning Time: 0.104 ms
Execution Time: 92.401 ms
Duas coisas gritam nesse plano. A primeira é Seq Scan: o Postgres leu a tabela inteira. A segunda é Rows Removed by Filter: 1999999 — ele olhou dois milhões de linhas para devolver uma. Havia um índice em cliente_id e ele não foi usado. Por quê?
Leia sempre o par estimado vs real. Um plano que estima
rows=1mas processa 2 milhões não é um problema de índice — é um problema de estatística. O Postgres tomou uma boa decisão com base em informação errada. Guarde isso; voltamos nele.
Os quatro motivos que impedem o índice
Quando o índice existe mas não é usado, o culpado quase sempre está nesta lista.
1. Você embrulhou a coluna numa função
Este é o campeão. Um índice em created_at indexa o valor da coluna. No momento em que você aplica uma função sobre ela no WHERE, o valor indexado deixa de existir para o planejador:
-- NÃO usa o índice em created_at:
WHERE created_at::date = '2026-07-01'
-- o índice guarda o timestamp, não o ::date dele
A correção não é indexar de outro jeito — é reescrever a condição para preservar a coluna crua, transformando a igualdade numa faixa:
-- USA o índice:
WHERE created_at >= '2026-07-01'
AND created_at < '2026-07-02'
O mesmo vale para lower(email) = '...', date_trunc(...), concatenações e cálculos na coluna. A regra tem nome: uma condição é SARGable (Search ARGument able) quando a coluna aparece sozinha de um lado do operador. Assim que você a embrulha, ela deixa de ser.
Quando a função é mesmo necessária — busca case-insensitive por e-mail, por exemplo — a saída é o índice de expressão, que indexa o resultado da função:
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- agora ISTO usa o índice:
WHERE lower(email) = 'pedro@matrix.dev'
2. Tipos que não batem
Um cast implícito no lado da coluna tem o mesmo efeito de uma função. Se codigo é varchar e você compara com um número, o Postgres precisa converter a coluna para comparar — e lá se vai o índice:
-- codigo é varchar; isto força cast da coluna:
WHERE codigo = 12345 -- vira codigo::int? ou 12345::text?
-- explícito e do lado certo, o índice volta:
WHERE codigo = '12345'
3. LIKE com curinga à esquerda
Um índice B-tree é uma árvore ordenada: ele acha rápido tudo que começa com um prefixo. Um curinga no início destrói essa premissa, porque não há prefixo por onde começar:
WHERE nome LIKE 'Silva%' -- usa índice (prefixo conhecido)
WHERE nome LIKE '%Silva' -- NÃO usa (prefixo desconhecido)
WHERE nome LIKE '%Silva%' -- NÃO usa
Busca por "contém" é outro problema, não um problema de índice B-tree. A resposta certa é pg_trgm com índice GIN, ou busca full-text — assunto para um artigo próprio.
4. O índice existe, mas usá-lo seria mais lento
Este não é bug: é o planejador acertando. Se a sua query devolve uma fração grande da tabela — digamos, 40% das linhas — ler a tabela inteira em sequência é mais rápido do que pular do índice para a tabela milhões de vezes. Índice compensa quando filtra muito; para baixa seletividade, o Seq Scan ganha.
Se o
EXPLAINmostra Seq Scan numa query que devolve metade da tabela, o Postgres está certo. O índice ali não ajudaria — e forçá-lo pioraria. O problema, se existir, é a query devolver linhas demais.
Índice composto: a ordem das colunas importa (muito)
Aqui está o erro que mais custa performance em query real. Um índice em duas colunas não é simétrico:
CREATE INDEX idx_pedidos ON pedidos (cliente_id, status);
Pense nele como uma lista telefônica ordenada por sobrenome e depois por nome. Ela serve para achar "Silva", e para achar "Silva, Pedro". Não serve para achar todos os "Pedro" de qualquer sobrenome — a ordenação por nome só vale dentro de cada sobrenome.
| Query | Usa (cliente_id, status)? |
|---|---|
WHERE cliente_id = 42 |
Sim — é o prefixo |
WHERE cliente_id = 42 AND status = 'pago' |
Sim — prefixo completo, ideal |
WHERE status = 'pago' |
Não — pula a primeira coluna |
Essa é a regra do prefixo à esquerda: o índice só serve para filtros que começam pela primeira coluna. Filtrar só por status não aproveita nada — você precisaria de um índice separado em status.
E a ordem certa das colunas segue duas diretrizes que valem quase sempre:
- Igualdade antes de faixa. Numa query
WHERE cliente_id = 42 AND criado_em > '2026-01-01', o índice ideal é(cliente_id, criado_em)— a coluna de igualdade primeiro, a de faixa depois. Inverter mata a eficiência. - A coluna do
ORDER BYpode entrar no índice. Se você filtra porcliente_ide ordena porcriado_em, o índice(cliente_id, criado_em)entrega os dados já ordenados — o Postgres pula a etapa deSort, que costuma ser o gargalo real.
Index-only scan: quando o Postgres nem toca na tabela
Todo Index Scan comum faz duas viagens: acha a linha no índice, depois vai buscar o resto dos dados na tabela (o heap). Essa segunda viagem — o heap fetch — é o custo escondido.
Se todas as colunas que a query precisa já estão no índice, o Postgres pula a tabela por completo. É o Index Only Scan, e a ferramenta para provocá-lo é o INCLUDE:
-- busca frequente: pega o total do pedido pelo cliente
CREATE INDEX idx_pedidos_cli
ON pedidos (cliente_id) INCLUDE (total, status);
-- esta query nunca toca no heap:
SELECT total, status FROM pedidos WHERE cliente_id = 42;
As colunas do INCLUDE não entram na ordenação do índice — só ficam "penduradas" nele para serem lidas sem ir à tabela. Use para as colunas que você seleciona, enquanto as colunas do WHERE e do ORDER BY ficam na chave.
Índice parcial: indexe só o que você consulta
Se as suas queries sempre filtram por um subconjunto — pedidos ativos, usuários não deletados — não faz sentido indexar a tabela inteira. O índice parcial indexa só as linhas que importam:
CREATE INDEX idx_pedidos_ativos
ON pedidos (cliente_id)
WHERE status = 'ativo';
O índice fica menor (mais rápido de ler e manter) e cobre exatamente o caso de uso. Num sistema onde 95% dos pedidos estão arquivados e você quase sempre consulta os 5% ativos, a diferença de tamanho é enorme.
O caso da estatística mentirosa
Lembra do plano lá do começo, que estimava rows=1 e processava milhões? Esse é o caso mais traiçoeiro, porque o índice, a query e os tipos estão todos certos — e ainda assim o plano é ruim.
O planejador decide com base em estatísticas sobre a distribuição dos dados: quantos valores distintos existem, quais são os mais comuns. Se essas estatísticas estão velhas — depois de uma carga grande, uma migração, um DELETE massivo — o planejador estima errado e escolhe o plano errado com total convicção.
ANALYZE pedidos; -- recalcula as estatísticas da tabela
Rode isso e refaça o EXPLAIN. Se o plano muda e a query acelera, o problema nunca foi o índice — era o autovacuum não ter passado a tempo. Em tabelas com escrita pesada, vale conferir se o autovacuum/autoanalyze está com frequência adequada.
Índice não é grátis
A tentação depois de ler tudo isso é indexar cada coluna. Não faça. Todo índice tem custo permanente:
- Escrita mais lenta. Todo
INSERT,UPDATEeDELETEprecisa atualizar cada índice da tabela. Dez índices = dez estruturas para manter a cada escrita. - Espaço em disco. Índices podem somar mais que a própria tabela.
- Índices redundantes. Se você tem
(cliente_id, status), um índice só em(cliente_id)provavelmente é redundante — o composto já cobre o prefixo. Manter os dois é pagar duas vezes.
Indexe para os padrões de query que você realmente tem, não para os que imagina que pode ter. E confira periodicamente pg_stat_user_indexes: índice com idx_scan = 0 nunca foi usado e só está custando escrita.
O roteiro quando a query está lenta
Da próxima vez, antes de criar qualquer índice:
- Rode
EXPLAIN (ANALYZE, BUFFERS)na query real. - Ache o Seq Scan caro e olhe
Rows Removed by Filter. - Compare rows estimado vs real. Gap grande → rode
ANALYZEantes de qualquer outra coisa. - Veja se o
WHEREembrulha a coluna em função ou cast. Se sim, reescreva para deixá-la crua. - Se for índice composto, confira a ordem das colunas contra a regra do prefixo à esquerda.
- Só então crie o índice — e rode o
EXPLAINde novo para confirmar que ele foi usado.
Em resumo
Query lenta com índice quase nunca é o Postgres falhando. É a coluna embrulhada numa função, o tipo que não bate, a ordem errada no índice composto ou a estatística velha enganando o planejador.
A ferramenta é uma só — EXPLAIN (ANALYZE, BUFFERS) — e ela transforma otimização de adivinhação em leitura. O banco já está te dizendo o que está fazendo e por quê. Só falta ler.