Erros comuns de legibilidade em SQL
Doze hábitos que deixam o SQL mais difícil de ler, revisar e confiar, cada um com um "antes" e uma reescrita mais clara. Alguns são puramente de layout, e um formatador resolve com um clique; outros exigem uma decisão humana, e alguns deles também mudam o que a consulta retorna ou a velocidade com que ela roda.
Por que vale a pena investir em legibilidade
Uma consulta é escrita uma vez e lida muitas vezes: na revisão de código, durante um incidente, quando alguém adiciona um filtro dois anos depois. A maioria dos bugs de SQL que chegam à produção não são erros de sintaxe; são consultas que fazem algo um pouco diferente do que o autor pretendia, e quem revisou não percebeu porque a consulta era difícil de ler. Os erros abaixo são os que mais aparecem em filas reais de revisão. Os erros 1 a 9 tratam de estrutura e layout; os erros 10 a 12 são problemas de legibilidade que também escondem uma armadilha de correção ou de desempenho.
Para as convenções de layout citadas aqui, veja o guia de estilo de SQL. Todos os blocos "depois" marcados como saída do formatador foram gerados pelo SQL Format com as opções padrão.
1. Joins implícitos com vírgula
Listar as tabelas no FROM separadas por vírgula e fazer o join no WHERE mistura condições de join com filtros. Com três tabelas ou mais, fica difícil ver qual condição liga o quê, e, se uma condição de join for esquecida, você ganha um produto cartesiano silencioso em vez de um erro.
SELECT c.name, o.id, o.total
FROM customers c, orders o, shipments s
WHERE o.customer_id = c.id
AND s.order_id = o.id
AND s.shipped_at IS NULL
AND o.status = 'paid'Use JOIN … ON explícito. Cada condição de join fica ao lado da tabela que ela traz, e o WHERE contém apenas os filtros. O formatador indenta cada JOIN abaixo do FROM e o seu ON um nível a mais:
SELECT c.name,
o.id,
o.total
FROM customers c
JOIN orders o
ON o.customer_id = c.id
JOIN shipments s
ON s.order_id = o.id
WHERE s.shipped_at IS NULL
AND o.status = 'paid'2. SELECT * em código de aplicação e de views
SELECT * não tem problema num prompt interativo. Em código, ele esconde de quais colunas quem chama depende, transfere colunas que ninguém lê e muda de comportamento quando alguém adiciona ou reordena uma coluna: uma view, um INSERT … SELECT ou um mapeamento posicional do resultado pode quebrar ou, pior, continuar funcionando com os dados errados.
SELECT *
FROM orders o
WHERE o.customer_id = 42Dê nome às colunas. O formatador mantém a primeira na linha do SELECT e coloca cada uma das outras na sua própria linha, de modo que quem revisa enxerga uma coluna adicionada ou removida como um diff de uma linha:
SELECT o.id,
o.created_at,
o.status,
o.total
FROM orders o
WHERE o.customer_id = 423. Aliases que não significam nada
Aliases como a, b, c ou t1, t2 obrigam quem lê a voltar até o FROM toda vez que uma coluna é citada. No exemplo abaixo, c.amount parece uma coluna de cliente, mas vem de pagamentos.
SELECT a.name, b.number, c.amount
FROM customers a
JOIN invoices b ON b.customer_id = a.id
JOIN payments c ON c.invoice_id = b.id
WHERE c.received_at >= DATE '2026-01-01'Escolha aliases que lembrem o nome da tabela. Numa consulta curta, uma inicial (o para orders) basta; quando as iniciais se repetem ou a consulta é longa, use uma palavra curta:
SELECT cust.name,
inv.number,
pay.amount
FROM customers cust
JOIN invoices inv
ON inv.customer_id = cust.id
JOIN payments pay
ON pay.invoice_id = inv.id
WHERE pay.received_at >= DATE '2026-01-01'4. Colunas não qualificadas em joins
Quando uma consulta faz join entre duas tabelas, um status ou name sem qualificação obriga quem lê a saber os dois esquemas de cor. Também é frágil: se alguém depois adicionar uma coluna status à segunda tabela, a consulta falha com um erro de "coluna ambígua", ou uma subconsulta correlacionada pode se ligar silenciosamente à tabela externa.
SELECT id, created_at, name, email
FROM orders o
JOIN customers c ON c.id = customer_id
WHERE status = 'open'
AND country = 'BR'Qualifique toda coluna com o seu alias assim que houver mais de uma tabela:
SELECT o.id,
o.created_at,
c.name,
c.email
FROM orders o
JOIN customers c
ON c.id = o.customer_id
WHERE o.status = 'open'
AND c.country = 'BR'5. Misturar AND e OR sem parênteses
O AND tem precedência maior que o OR. A condição abaixo é avaliada como (country = 'BR' AND status = 'active') OR (status = 'trial' AND deleted_at IS NULL), então ela retorna clientes em trial de todos os países. Isso quase nunca é o que o autor queria, e o layout achatado esconde o problema.
SELECT id, name, status
FROM customers
WHERE country = 'BR' AND status = 'active' OR status = 'trial' AND deleted_at IS NULLColoque parênteses sempre que AND e OR aparecerem na mesma condição, mesmo quando a precedência por acaso dá a resposta certa. O formatador coloca cada condição de nível superior na sua própria linha e indenta o grupo entre parênteses, de modo que a estrutura fica visível de relance:
SELECT id,
name,
status
FROM customers
WHERE country = 'BR'
AND (
status = 'active'
OR status = 'trial'
)
AND deleted_at IS NULL6. Subconsultas profundamente aninhadas em vez de CTEs
Tabelas derivadas aninhadas em dois ou três níveis precisam ser lidas de dentro para fora, e os seus nomes (p, t) só aparecem depois do código que as define.
SELECT c.name, t.revenue
FROM (SELECT customer_id, revenue
FROM (SELECT customer_id, SUM(total) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id) p
WHERE revenue > 10000) t
JOIN customers c ON c.id = t.customer_id
ORDER BY t.revenue DESCUma common table expression (WITH) permite dar nome a cada etapa e ler a consulta de cima para baixo. Todos os bancos de dados cobertos na nossa comparação de dialetos suportam CTEs nas versões atuais. O formatador organiza o corpo de cada CTE como qualquer outra subconsulta, indentado dentro dos seus parênteses:
WITH paid_orders AS (
SELECT customer_id,
SUM(total) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
),
top_customers AS (
SELECT customer_id,
revenue
FROM paid_orders
WHERE revenue > 10000
)
SELECT c.name,
t.revenue
FROM top_customers t
JOIN customers c
ON c.id = t.customer_id
ORDER BY t.revenue DESCMais técnicas para consultas grandes estão em formatando consultas complexas.
7. Números mágicos sem explicação
Um status_id = 4 ou - 90 solto não diz nada a quem lê. Alguém precisa descobrir o que é o status 4, e ninguém sabe se 90 é uma regra de negócio ou um chute.
SELECT id, name
FROM employees
WHERE status_id = 4
AND hired_at < CURRENT_DATE - 90Faça join com a tabela de domínio e filtre por um código legível, e coloque um comentário ao lado de qualquer constante que represente uma regra. O formatador preserva os comentários no lugar em que estão:
SELECT e.id,
e.name
FROM employees e
JOIN employee_status s
ON s.id = e.status_id
WHERE s.code = 'active'
-- probation period is 90 days (HR policy)
AND e.hired_at < CURRENT_DATE - INTERVAL '90 days'8. Caixa inconsistente nas palavras-chave
SELECT numa linha, from na seguinte e order BY depois disso não está errado, mas torna mais difícil distinguir palavras-chave de identificadores e transforma cada edição em ruído no diff. Escolha uma convenção para o time e deixe uma ferramenta aplicá-la. Com a opção Palavras-chave definida como Maiúsculas, o formatador reescreve as palavras-chave e mantém identificadores como Name e Customers do jeito que foram digitados:
SELECT id, Name, email
from Customers
Where status = 'active'
order BY created_at DESCSELECT id,
Name,
email
FROM Customers
WHERE status = 'active'
ORDER BY created_at DESC9. A consulta inteira numa linha só
Consultas copiadas de logs, ORMs ou chats costumam chegar como uma única linha de várias centenas de caracteres. Revisá-las significa rolar para os lados, e um diff nessa linha mostra a consulta inteira como alterada. Este é o caso em que um formatador mais ajuda:
select p.sku, p.name, sum(i.quantity) as units, sum(i.quantity * i.unit_price) as revenue from order_items i join products p on p.id = i.product_id join orders o on o.id = i.order_id where o.status = 'paid' and o.created_at >= date '2026-01-01' group by p.sku, p.name having sum(i.quantity) > 100 order by revenue descSELECT p.sku,
p.name,
SUM(i.quantity) AS units,
SUM(i.quantity * i.unit_price) AS revenue
FROM order_items i
JOIN products p
ON p.id = i.product_id
JOIN orders o
ON o.id = i.order_id
WHERE o.status = 'paid'
AND o.created_at >= DATE '2026-01-01'
GROUP BY p.sku,
p.name
HAVING SUM(i.quantity) > 100
ORDER BY revenue DESCSe algumas linhas continuarem longas demais depois da organização do layout, a opção Quantidade máxima de colunas as quebra num espaço.
10. ORDER BY e GROUP BY por posição
ORDER BY 1, 3 é rápido de digitar, mas obriga quem lê a contar os itens do select, e muda de significado silenciosamente quando alguém insere uma coluna no começo da lista do select. O GROUP BY posicional não é suportado em todo lugar, e a coluna calculada abaixo nem sequer tem nome.
SELECT region, product_line, SUM(amount)
FROM sales
GROUP BY 1, 2
ORDER BY 1, 3 DESCAgrupe pelas expressões e ordene por nomes de coluna ou aliases:
SELECT region,
product_line,
SUM(amount) AS revenue
FROM sales
GROUP BY region,
product_line
ORDER BY region,
revenue DESC11. NOT IN com uma subconsulta que pode retornar NULL
Este parece inofensivo e é um bug de correção. x NOT IN (a, b, NULL) significa x <> a AND x <> b AND x <> NULL, e x <> NULL nunca é verdadeiro: é desconhecido. Então, se a subconsulta retornar um único NULL, o predicado inteiro nunca é verdadeiro e a consulta não retorna nenhuma linha.
-- returns no rows at all if any blocked_emails.email is NULL
SELECT c.id, c.name
FROM customers c
WHERE c.email NOT IN (SELECT email FROM blocked_emails)Use NOT EXISTS, que compara linha a linha e não é afetado por valores NULL na outra tabela. Ele também expressa a intenção, "clientes sem nenhum e-mail bloqueado correspondente", de forma mais direta. O formatador mantém NOT EXISTS ( na linha do WHERE e indenta a subconsulta correlacionada logo abaixo:
SELECT c.id,
c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM blocked_emails b
WHERE b.email = c.email
)Um anti-join com LEFT JOIN … IS NULL dá o mesmo resultado e é questão de preferência do time; teste a coluna que não pode ser NULL numa linha correspondente, geralmente a chave do join:
SELECT c.id,
c.name
FROM customers c
LEFT JOIN blocked_emails b
ON b.email = c.email
WHERE b.email IS NULL12. Funções sobre colunas indexadas no WHERE
Envolver uma coluna numa função, como em EXTRACT(YEAR FROM created_at) = 2026, parece natural, mas geralmente impede o banco de dados de usar um índice simples nessa coluna, porque o índice armazena created_at, não o ano. Diz-se que o predicado é non-sargable, e a consulta acaba fazendo uma varredura da tabela. Também é mais difícil de ler do que parece: duas condições separadas descrevem o que na verdade é um único intervalo de datas.
-- an index on created_at cannot be used for this range
SELECT id, total
FROM orders
WHERE EXTRACT(YEAR FROM created_at) = 2026
AND EXTRACT(MONTH FROM created_at) = 3Escreva o intervalo sobre a coluna pura, com início inclusivo e fim exclusivo. Isso funciona para qualquer tipo de coluna, inclusive timestamps com parte de hora:
SELECT id,
total
FROM orders
WHERE created_at >= DATE '2026-03-01'
AND created_at < DATE '2026-04-01'Um índice de expressão pode deixar a forma com função rápida de novo, mas o intervalo é mais simples e não depende de um índice que alguém pode apagar.
O que um formatador corrige e o que não corrige
Um formatador corrige o layout: os erros 8 e 9 por completo, e o lado visual dos outros, como uma coluna por linha e uma condição por linha. Ele não muda o significado da sua consulta, então não vai adicionar parênteses, substituir NOT IN nem renomear aliases. Formatar primeiro ainda ajuda nesses casos, porque uma consulta bem organizada deixa os problemas restantes mais fáceis de enxergar. Para manter isso consistente em todo o time, veja formatação de SQL na revisão de código e no controle de versão.
Perguntas frequentes
SELECT * está sempre errado?
Não. Ele não tem problema em explorações ad hoc e dentro de subconsultas EXISTS, em que a lista do select é ignorada. O problema é código de aplicação, views e comandos INSERT … SELECT que dependem silenciosamente da lista atual de colunas.
Joins implícitos com vírgula são mais lentos que JOIN explícito?
Para inner joins, os otimizadores mais usados geralmente tratam as duas formas da mesma maneira. O motivo para preferir JOIN explícito é legibilidade e segurança: uma condição de join esquecida fica visível em vez de produzir um produto cartesiano.
O NOT IN alguma vez se comporta corretamente?
Sim, quando nem a lista nem a subconsulta podem produzir NULL, por exemplo uma lista de literais ou uma coluna declarada NOT NULL. Como as constraints podem mudar depois, NOT EXISTS é o hábito mais seguro para subconsultas.