DevGuideTK
← Trilha Backend

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 ms

O plano é uma árvore, lida de dentro para fora: o nó mais recuado roda primeiro e entrega as linhas ao de cima. Em cada nó:

ParteSignificado
cost=4.51..47.80Custo estimado, numa unidade interna: o primeiro número até a primeira linha, o segundo até a última
rows=11Quantas linhas o planejador estimou
actual time=0.070..0.101Tempo real, em milissegundos (só com ANALYZE)
rows=6.00 (em actual)Quantas linhas saíram de verdade
loops=1Quantas vezes o nó rodou; o tempo e as linhas são por execução
Buffers: shared hit=9 read=3Pá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 fazBom quando
Seq ScanLê a tabela inteiraA tabela é pequena, ou a consulta devolve boa parte dela
Index ScanPercorre o índice e busca cada linha na tabelaPoucas linhas, ou a ordem do índice serve para um ORDER BY
Index Only ScanResponde só com o índice, sem ir à tabelaO índice tem todas as colunas pedidas
Bitmap Heap ScanJunta as posições achadas no índice e lê a tabela em ordem físicaUm número médio de linhas
Nested LoopPara cada linha de um lado, busca no outroUm lado tem poucas linhas e o outro tem índice
Hash JoinMonta uma tabela de hash com um lado e percorre o outroOs dois lados são grandes
SortOrdena na memória, ou em disco se não couberNão há índice na ordem pedida
GatherJunta o trabalho de processos paralelosVarreduras 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:

ConsultaPlanoTempo
WHERE email = '...'Index Scan0,09 ms
WHERE lower(email) = '...'Parallel Seq Scan, 100 mil linhas descartadas por processo37,5 ms
WHERE criado_em >= '2025-03-01' AND criado_em < '2025-03-02'Index Only Scan0,79 ms
WHERE date(criado_em) = '2025-03-01'Parallel Seq Scan71,3 ms
WHERE cliente_id = 4242Bitmap Index Scan0,18 ms
WHERE cliente_id = 4242::numericParallel Seq Scan72,1 ms
ORDER BY criado_em DESC LIMIT 10Index Scan Backward0,09 ms
ORDER BY total DESC LIMIT 10 (sem índice em total)Sort sobre 2 milhões de linhas108 ms

Três padrões aparecem:

  • Função na coluna. O índice guarda email, e não lower(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 de date(criado_em).
  • Tipo trocado. cliente_id é bigint, e o valor veio como numeric. Para comparar, o banco converteu a coluna em cada linha: o plano mostra Filter: ((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 reaisPlanoTempo
Estatísticas antigas1 linha500.000Nested Loop: 500 mil buscas no índice de clientes795 ms
Depois do ANALYZE500.000500.000Parallel Hash Join100 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, como lower(email) ou date(criado_em), sem o índice correspondente.
  • Parâmetros com o tipo diferente da coluna.
  • ORDER BY em colunas sem índice, em listagens com LIMIT.
  • LIKE '%texto%' para busca.
  • Índices criados "por garantia", sem conferir com o EXPLAIN se o plano os usa.
  • Nenhum ANALYZE depois 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.