Plano de execução
Como ler o EXPLAIN para entender por que uma consulta está lenta.
O problema: o SQL diz o quê, não como
Uma consulta SQL descreve o resultado que você quer, e não como obtê-lo. Quem decide o caminho é o planejador do banco: qual tabela ler primeiro, se usa um índice ou varre a tabela inteira, como junta duas tabelas, se ordena na memória. O conjunto dessas decisões é o plano de execução.
O planejador escolhe o plano que estima ser o mais barato, com base em estatísticas sobre os dados. Quando a estimativa erra, ou quando a consulta impede o uso de um índice, o plano escolhido pode ser mil vezes mais lento que o melhor possível. O EXPLAIN mostra o plano, e é a primeira ferramenta para entender uma consulta lenta.
Lendo um plano
Os exemplos desta página foram medidos num PostgreSQL 18, com 200 mil clientes e 2 milhões de pedidos.
EXPLAIN ANALYZE SELECT * FROM pedidos WHERE cliente_id = 4242;
Bitmap Heap Scan on pedidos (cost=4.51..47.80 rows=11 width=38)
(actual time=0.070..0.101 rows=6.00 loops=1)
Recheck Cond: (cliente_id = 4242)
Buffers: shared hit=9 read=3
-> Bitmap Index Scan on pedidos_cliente_id_idx (cost=0.00..4.51 rows=11 width=0)
(actual time=0.053..0.053 rows=6.00 loops=1)
Index Cond: (cliente_id = 4242)
Execution Time: 0.175 msO plano é uma árvore, lida de dentro para fora: o nó mais recuado roda primeiro e entrega as linhas ao de cima. Em cada nó:
| Parte | Significado |
|---|---|
cost=4.51..47.80 | Custo estimado, numa unidade interna: o primeiro número até a primeira linha, o segundo até a última |
rows=11 | Quantas linhas o planejador estimou |
actual time=0.070..0.101 | Tempo real, em milissegundos (só com ANALYZE) |
rows=6.00 (em actual) | Quantas linhas saíram de verdade |
loops=1 | Quantas vezes o nó rodou; o tempo e as linhas são por execução |
Buffers: shared hit=9 read=3 | Páginas de 8 KB lidas da memória (hit) e do disco (read) |
EXPLAIN sozinho só mostra o plano e as estimativas, sem executar. EXPLAIN ANALYZE executa a consulta de verdade e acrescenta os números reais. Cuidado com EXPLAIN ANALYZE em UPDATE ou DELETE: a alteração acontece. Rode dentro de uma transação e desfaça com ROLLBACK.
Os nós mais comuns
| Nó | O que faz | Bom quando |
|---|---|---|
| Seq Scan | Lê a tabela inteira | A tabela é pequena, ou a consulta devolve boa parte dela |
| Index Scan | Percorre o índice e busca cada linha na tabela | Poucas linhas, ou a ordem do índice serve para um ORDER BY |
| Index Only Scan | Responde só com o índice, sem ir à tabela | O índice tem todas as colunas pedidas |
| Bitmap Heap Scan | Junta as posições achadas no índice e lê a tabela em ordem física | Um número médio de linhas |
| Nested Loop | Para cada linha de um lado, busca no outro | Um lado tem poucas linhas e o outro tem índice |
| Hash Join | Monta uma tabela de hash com um lado e percorre o outro | Os dois lados são grandes |
| Sort | Ordena na memória, ou em disco se não couber | Não há índice na ordem pedida |
| Gather | Junta o trabalho de processos paralelos | Varreduras grandes |
Um Seq Scan não é sempre ruim. Para uma tabela de 50 linhas, ou para uma consulta que lê 40% da tabela, é o caminho mais rápido. O sinal de problema é um Seq Scan numa tabela grande que devolve poucas linhas: o Rows Removed by Filter fica enorme.
O que impede o uso do índice
Quatro pares de consultas, cada versão que usa o índice ao lado da que não usa:
| Consulta | Plano | Tempo |
|---|---|---|
WHERE email = '...' | Index Scan | 0,09 ms |
WHERE lower(email) = '...' | Parallel Seq Scan, 100 mil linhas descartadas por processo | 37,5 ms |
WHERE criado_em >= '2025-03-01' AND criado_em < '2025-03-02' | Index Only Scan | 0,79 ms |
WHERE date(criado_em) = '2025-03-01' | Parallel Seq Scan | 71,3 ms |
WHERE cliente_id = 4242 | Bitmap Index Scan | 0,18 ms |
WHERE cliente_id = 4242::numeric | Parallel Seq Scan | 72,1 ms |
ORDER BY criado_em DESC LIMIT 10 | Index Scan Backward | 0,09 ms |
ORDER BY total DESC LIMIT 10 (sem índice em total) | Sort sobre 2 milhões de linhas | 108 ms |
Três padrões aparecem:
- Função na coluna. O índice guarda
email, e nãolower(email). Para o banco, são valores diferentes. A saída é um índice na expressão (CREATE INDEX ON clientes (lower(email))) ou reescrever a condição, como a faixa de datas no lugar dedate(criado_em). - Tipo trocado.
cliente_idébigint, e o valor veio comonumeric. Para comparar, o banco converteu a coluna em cada linha: o plano mostraFilter: ((cliente_id)::numeric = '4242'::numeric). Isso acontece quando o driver ou o ORM envia o parâmetro com um tipo diferente da coluna. - Ordenação sem índice. Com
LIMIT 10, o banco ainda precisa olhar as 2 milhões de linhas para achar as 10 maiores.
O LIKE e a collation
O teste trouxe uma surpresa: WHERE email LIKE 'cliente4242@%', uma busca por prefixo, também fez Seq Scan, mesmo com o índice em email. O banco do teste usa a collation en_US.utf8, a padrão da imagem Docker oficial, e com ela o índice B-tree comum não serve para LIKE. Com um índice criado com text_pattern_ops, a mesma consulta passou de 14,9 ms para 0,085 ms, com Index Scan. Bancos com collation C não têm esse problema. Uma busca com o curinga no começo (LIKE '%4242@%') não usa nenhum dos dois índices; ela precisa de busca textual, como mostra o tópico de busca textual.
Estimativas erradas, planos errados
O planejador decide com base em estatísticas: quantas linhas cada tabela tem e como os valores se distribuem. O processo de manutenção automática (autovacuum) atualiza essas estatísticas de tempos em tempos, e o comando ANALYZE as atualiza na hora.
Para ver o efeito, criamos uma tabela de eventos com 500 mil linhas do tipo visita, atualizamos as estatísticas e, em seguida, inserimos 500 mil linhas de um tipo novo, compra, com a manutenção automática desligada. A consulta que junta as compras com os clientes:
Estimativa para tipo = 'compra' | Linhas reais | Plano | Tempo | |
|---|---|---|---|---|
| Estatísticas antigas | 1 linha | 500.000 | Nested Loop: 500 mil buscas no índice de clientes | 795 ms |
Depois do ANALYZE | 500.000 | 500.000 | Parallel Hash Join | 100 ms |
O planejador achou que compra não existia, porque não existia quando as estatísticas foram coletadas. Para uma linha, o nested loop é o melhor plano. Para 500 mil, é oito vezes mais lento. A pista no plano é a diferença entre rows=1 estimado e rows=500000 real. Estimativas que erram por ordens de grandeza são a primeira coisa a procurar num plano ruim.
Isso acontece de verdade depois de cargas grandes de dados, em tabelas recém-criadas e em colunas com valores muito concentrados. Rodar ANALYZE depois de uma carga é um hábito barato.
Por que isso importa quando a IA escreve o código
Um assistente que escreve consultas, ou que as gera por um ORM, costuma produzir SQL correto e lento:
- Funções na coluna do
WHERE, comolower(email)oudate(criado_em), sem o índice correspondente. - Parâmetros com o tipo diferente da coluna.
ORDER BYem colunas sem índice, em listagens comLIMIT.LIKE '%texto%'para busca.- Índices criados "por garantia", sem conferir com o
EXPLAINse o plano os usa. - Nenhum
ANALYZEdepois de migrações que carregam muitos dados.
O próximo nível mostra como investigar uma consulta lenta passo a passo e como manter os planos sob controle.
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.
Plano de execução em cinco minutos
Explique em no máximo cinco minutos de leitura o que é o plano de execução de um banco relacional, para alguém que escreve SQL, mas nunca usou o EXPLAIN. Cubra só o essencial: o que o planejador decide, como ler um nó do EXPLAIN ANALYZE, a diferença entre linhas estimadas e reais, e os erros mais comuns que impedem o uso de um índice. Use o exemplo de uma tabela de pedidos. No final, me faça três perguntas curtas para eu verificar se entendi, e corrija as minhas respostas.
Lendo um EXPLAIN ANALYZE linha a linha
Quero aprender a ler a saída do EXPLAIN ANALYZE do PostgreSQL. Aja como um mentor e vá por etapas, esperando a minha resposta em cada uma: 1. Me mostre um plano com três ou quatro nós e peça que eu diga em que ordem eles rodam. 2. Explique cost, rows, actual time, loops e Buffers, e peça que eu calcule o tempo total de um nó com loops maior que 1. 3. Me mostre um plano com uma estimativa muito errada e peça que eu ache o problema. 4. Me mostre um Seq Scan com Rows Removed by Filter alto e peça que eu proponha a correção. 5. Me mostre um Sort com external merge e pergunte o que ele significa.
Os tipos de junção e de varredura
Quero entender como o banco escolhe entre os tipos de varredura e de junção. Explique, com um exemplo de quando cada um é a melhor escolha: 1. Seq Scan, Index Scan, Index Only Scan e Bitmap Heap Scan. 2. Nested Loop, Hash Join e Merge Join. 3. Por que um Seq Scan às vezes é o plano certo, mesmo com índice. 4. Como o volume de linhas estimado muda a escolha. Depois me descreva quatro consultas com volumes diferentes e pergunte qual plano o banco deveria escolher.
Por que o índice não está sendo usado
Quero entender os motivos pelos quais o banco ignora um índice que parece servir. Explique, com a consulta errada, o plano e a correção: 1. Função aplicada na coluna. 2. Tipo do parâmetro diferente do tipo da coluna. 3. LIKE com curinga no começo, e LIKE por prefixo com collation diferente de C. 4. Ordem das colunas num índice composto. 5. Estatísticas desatualizadas. Para cada um, me mostre o plano e peça que eu descubra a causa antes de você explicar.