Como formatar consultas SQL complexas
Como organizar consultas que ocupam uma tela ou mais: cadeias de CTEs, subconsultas aninhadas, expressões CASE, funções de janela e UNION. Cada seção mostra o que o SQL Format produz e os hábitos manuais que deixam o resultado mais fácil de acompanhar.
Uma consulta curta é legível em praticamente qualquer layout. Uma longa só é legível se o seu formato mostrar onde cada parte começa e termina. A formatação não conserta uma consulta que faz coisas demais ao mesmo tempo, mas pode deixar a estrutura visível, e é uma estrutura visível que permite identificar a parte que precisa ser reescrita. Os exemplos abaixo usam as opções padrão do formatador: indentação de quatro espaços, vírgulas à direita e palavras-chave em maiúsculas.
Princípios gerais
- Uma ideia por bloco. Cada CTE, subconsulta ou expressão CASE deve fazer uma única coisa, que você consiga descrever numa frase curta.
- A indentação mostra o aninhamento. Tudo o que está dentro de um par de parênteses deve ficar indentado em relação à linha que o abriu, para que você encontre o fechamento correspondente olhando direto para baixo.
- Leia de cima para baixo. Prefira estruturas que podem ser lidas em sequência (etapas nomeadas) a estruturas que precisam ser lidas de dentro para fora (aninhamento profundo).
- Nomes são documentação. Um bom nome de CTE ou alias de coluna substitui um comentário.
CTEs: uma cadeia de etapas nomeadas
Uma common table expression permite dividir uma consulta em etapas nomeadas, cada uma construída a partir das anteriores. Pense nela como um pipeline: filtrar as linhas brutas, agregá-las, classificá-las e, por fim, selecionar as colunas finais. Veja uma cadeia de três etapas escrita em uma linha e depois formatada:
with paid_orders as (select order_id, customer_id, total_amount, created_at from orders where status = 'paid' and created_at >= date '2026-01-01'), customer_revenue as (select customer_id, count(*) as order_count, sum(total_amount) as revenue from paid_orders group by customer_id), ranked_customers as (select customer_id, order_count, revenue, RANK() over (order by revenue desc) as revenue_rank from customer_revenue) select c.customer_name, r.order_count, r.revenue, r.revenue_rank from ranked_customers r join customers c on c.customer_id = r.customer_id where r.revenue_rank <= 10 order by r.revenue_rank;WITH paid_orders AS (
SELECT order_id,
customer_id,
total_amount,
created_at
FROM orders
WHERE status = 'paid'
AND created_at >= DATE '2026-01-01'
),
customer_revenue AS (
SELECT customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM paid_orders
GROUP BY customer_id
),
ranked_customers AS (
SELECT customer_id,
order_count,
revenue,
RANK() OVER (
ORDER BY revenue DESC
) AS revenue_rank
FROM customer_revenue
)
SELECT c.customer_name,
r.order_count,
r.revenue,
r.revenue_rank
FROM ranked_customers r
JOIN customers c
ON c.customer_id = r.customer_id
WHERE r.revenue_rank <= 10
ORDER BY r.revenue_rank;O formatador não tem um layout específico para CTEs; ele aplica as mesmas regras que usa em todo o resto. O corpo de cada CTE é uma subconsulta, então fica indentado dentro dos seus parênteses. O parêntese de fechamento volta para a margem esquerda, a vírgula entre as CTEs vem logo depois dele, na mesma linha, e o nome da próxima CTE começa uma nova linha. Com vírgulas à esquerda, a vírgula passa para o início do próximo nome (,customer_revenue AS (). Com a opção Parênteses em nova linha ativada, o parêntese de abertura depois do AS passa para uma linha própria, o que algumas pessoas acham mais fácil de casar visualmente.
O que o formatador não pode fazer por você é a estrutura em si. Alguns hábitos deixam as cadeias de CTEs muito mais fáceis de ler:
- Dê a cada etapa o nome do seu resultado, não de como ela é construída:
paid_orders,customer_revenue,ranked_customers. Evitecte1,tmpoubase. - Uma transformação por CTE. Se uma etapa filtra, faz join, agrega e classifica, divida-a. As linhas a mais custam menos do que o tempo que um revisor gasta para desembaraçá-la.
- Mantenha o SELECT final curto. O ideal é que ele apenas faça o join da última etapa com as tabelas de lookup e escolha as colunas de saída.
- Selecione apenas as colunas de que a próxima etapa precisa. Isso deixa claras as entradas de cada etapa e torna as colunas não usadas óbvias.
- Depure etapa por etapa. Uma cadeia nomeada permite trocar o SELECT final por
SELECT * FROM customer_revenuepara inspecionar um resultado intermediário.
Subconsultas aninhadas
Subconsultas no WHERE, principalmente depois de um IN, costumam ser a forma mais clara de expressar um filtro. Elas ficam difíceis de ler quando se aninham. O formatador indenta cada nível dentro dos seus parênteses, então pelo menos a profundidade fica visível:
select p.product_id, p.product_name from products p where p.category_id in (select category_id from categories where parent_id in (select category_id from categories where category_name = 'Hardware')) and p.discontinued = false;SELECT p.product_id,
p.product_name
FROM products p
WHERE p.category_id IN (
SELECT category_id
FROM categories
WHERE parent_id IN (
SELECT category_id
FROM categories
WHERE category_name = 'Hardware'
)
)
AND p.discontinued = false;Cada subconsulta abre com um parêntese no fim da linha do IN, e o seu parêntese de fechamento fica alinhado com o início da linha que o abriu. A última condição, AND p.discontinued = false, volta para o nível externo, o que deixa óbvio que ela pertence à consulta externa e não à subconsulta. Observe que TRUE e FALSE são sempre escritos em minúsculas por este formatador.
Se você prefere cada parêntese de abertura numa linha própria, a opção Parênteses em nova linha gera este layout:
SELECT p.product_id,
p.product_name
FROM products p
WHERE p.category_id IN
(
SELECT category_id
FROM categories
WHERE parent_id IN
(
SELECT category_id
FROM categories
WHERE category_name = 'Hardware'
)
)
AND p.discontinued = false;Dois níveis de aninhamento costumam ser o ponto em que a legibilidade começa a sofrer. A partir daí, considere mover a consulta interna para uma CTE com um nome descritivo (aqui, algo como hardware_categories), ou substituir uma cadeia de subconsultas IN por joins quando o relacionamento for simples.
Subconsultas correlacionadas e EXISTS
Subconsultas correlacionadas, que fazem referência a uma coluna da consulta externa, merecem cuidado extra, porque a ligação entre os dois níveis passa despercebida com facilidade. Coloque a condição de correlação primeiro no WHERE interno e qualifique os dois lados com os seus aliases. O formatador mantém o NOT EXISTS na linha da condição, como AND NOT EXISTS (, e indenta a subconsulta logo abaixo, como qualquer outra:
select c.customer_id, c.customer_name from customers c where c.is_active = true and not exists (select 1 from orders o where o.customer_id = c.customer_id and o.created_at >= current_date - 365)SELECT c.customer_id,
c.customer_name
FROM customers c
WHERE c.is_active = true
AND NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.created_at >= CURRENT_DATE - 365
)O SELECT 1 é uma convenção: o EXISTS só verifica se existe alguma linha, então a lista do select é irrelevante, e escrever uma constante deixa isso claro para quem lê.
Expressões CASE
Uma expressão CASE com vários ramos espremidos em uma linha é uma das coisas mais difíceis de ler em SQL. O formatador dá ao CASE um layout próprio, mostrado aqui para um CASE pesquisado (condições depois de cada WHEN) e para um CASE simples (um valor comparado com constantes):
select order_id, total_amount, case when total_amount >= 1000 then 'large' when total_amount >= 100 then 'medium' else 'small' end as order_size, case status when 'open' then 1 when 'shipped' then 2 else 3 end as status_rank from orders;SELECT order_id,
total_amount,
CASE WHEN total_amount >= 1000 THEN
'large'
WHEN total_amount >= 100 THEN
'medium'
ELSE
'small'
END AS order_size,
CASE status WHEN 'open' THEN
1
WHEN 'shipped' THEN
2
ELSE
3
END AS status_rank
FROM orders;O primeiro WHEN fica na linha do CASE e cada WHEN seguinte começa uma nova linha, sempre junto com a sua condição e o THEN. O resultado vai na linha seguinte, indentado um nível, e ELSE e END ficam alinhados com os ramos WHEN. O alias vem depois do END, então o nome da coluna calculada é fácil de encontrar.
Dicas manuais para CASE:
- Ordene os ramos de propósito. Um CASE pesquisado retorna o primeiro ramo que corresponder, então coloque a condição mais específica primeiro. No exemplo,
>= 1000precisa vir antes de>= 100. - Sempre escreva um ELSE. Sem ele, as linhas que não correspondem a nenhum ramo retornam
NULL. Se é essa a intenção, escrevaELSE NULLpara que quem lê saiba que não foi esquecimento. - Mantenha os tipos de resultado consistentes. Misturar números e strings entre os ramos leva a erros de conversão ou a casts implícitos inesperados.
- Tire os mapeamentos grandes da consulta. Um CASE com vinte ramos que mapeia códigos para rótulos geralmente é uma tabela de lookup esperando para ser criada.
Funções de janela
Funções de janela calculam um valor sobre um conjunto de linhas relacionadas sem agrupá-las, o que as torna ideais para rankings, totais acumulados e comparações com a linha anterior. A cláusula OVER pode ficar longa, com partição, ordenação e frame. Veja como o formatador as organiza:
select customer_id, order_id, total_amount, ROW_NUMBER() over (partition by customer_id order by created_at desc) as recency_rank, sum(total_amount) over (partition by customer_id order by created_at ROWS between UNBOUNDED PRECEDING and CURRENT ROW) as running_total from orders where status <> 'cancelled';SELECT customer_id,
order_id,
total_amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS recency_rank,
SUM(total_amount) OVER (
PARTITION BY customer_id
ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
WHERE status <> 'cancelled';O OVER ( fica na mesma linha da função, e a especificação da janela é indentada logo abaixo: PARTITION BY e ORDER BY começam cada um a sua própria linha, e o parêntese de fechamento volta para a indentação da função, seguido do alias. A cláusula de frame (ROWS BETWEEN ...) fica na linha do ORDER BY, e as suas palavras (ROWS, UNBOUNDED PRECEDING, CURRENT ROW) têm a caixa alterada como qualquer outra palavra-chave. Nomes de funções de janela como ROW_NUMBER e RANK não são tratados como palavras-chave e mantêm a caixa que você digitou, então escreva-os de forma consistente à mão.
Dicas manuais para funções de janela:
- Sempre dê um alias ao resultado, com um nome que diga o que ele significa:
recency_rank,running_total, e nãornousum1. - Seja explícito quanto ao frame quando usar uma função de agregação com
ORDER BY. É fácil errar com o frame padrão quando há empates na coluna de ordenação. - Reaproveite a definição da janela. Quando várias funções compartilham o mesmo
PARTITION BYeORDER BY, os bancos que suportam a cláusulaWINDOWnomeada, entre eles o PostgreSQL, permitem defini-la uma vez e referenciá-la pelo nome. - Filtre numa etapa externa. Funções de janela não podem ser usadas no
WHEREdo mesmo nível. Para manter apenas a primeira linha de cada grupo, calcule o ranking numa CTE e filtre por ele na etapa seguinte.
UNION e UNION ALL
As operações de conjunto combinam os resultados de vários comandos SELECT. O segredo para lê-las é enxergar onde um ramo termina e o próximo começa:
select customer_id, 'order' as event_type, created_at from orders where created_at >= current_date - 7 union all select customer_id, 'refund' as event_type, created_at from refunds where created_at >= current_date - 7 union select customer_id, 'signup' as event_type, created_at from customers where created_at >= current_date - 7 order by created_at;SELECT customer_id,
'order' AS event_type,
created_at
FROM orders
WHERE created_at >= CURRENT_DATE - 7
UNION ALL
SELECT customer_id,
'refund' AS event_type,
created_at
FROM refunds
WHERE created_at >= CURRENT_DATE - 7
UNION
SELECT customer_id,
'signup' AS event_type,
created_at
FROM customers
WHERE created_at >= CURRENT_DATE - 7
ORDER BY created_at;Cada UNION ou UNION ALL fica numa linha própria, na margem esquerda, entre dois blocos SELECT que mantêm o seu layout normal. O ORDER BY final aparece depois do último ramo. Ele ordena o resultado combinado, e não apenas o último ramo, o que é fácil de interpretar errado se os ramos não estiverem bem separados.
Dicas manuais para operações de conjunto:
- Use UNION ALL por padrão. O
UNIONremove linhas duplicadas, o que custa uma ordenação ou um hash e pode esconder problemas nos dados. Use-o apenas quando você realmente precisa eliminar duplicatas, e considere deixar um comentário dizendo isso. - Misturar UNION e UNION ALL, como no exemplo, é permitido, mas sutil: a eliminação de duplicatas se aplica ao resultado montado até aquele ponto, e não apenas ao último ramo. Quando isso não for óbvio, divida a consulta em CTEs.
- Alinhe as listas do select. As colunas são casadas pela posição, não pelo nome. Dê a todos os ramos as mesmas colunas, na mesma ordem, com os mesmos aliases, e adicione uma coluna literal como
event_typepara indicar de onde veio cada linha. - Mantenha cada ramo simples. Se um ramo precisa dos seus próprios joins e filtros, considere uma CTE por ramo e um UNION final das CTEs.
Linhas longas e quebra de linha
Mesmo com uma boa estrutura, algumas linhas ficam longas: uma expressão aritmética extensa, uma função com muitos argumentos, uma longa lista de condições. A opção Quantidade máxima de colunas, 120 por padrão, quebra qualquer linha da saída maior que o limite no último espaço antes dele e indenta a continuação. Ela é uma passada final sobre a saída já pronta, então não reorganiza as cláusulas. Se uma linha continua difícil de ler depois da quebra, isso é sinal de que vale introduzir uma CTE ou um alias intermediário, e não de diminuir o limite.
Um checklist rápido para consultas complexas
- Cada CTE pode ser descrita numa frase curta, e o seu nome diz o que ela retorna?
- Alguma subconsulta está aninhada com mais de dois níveis de profundidade?
- Todo CASE tem um ELSE, e os seus ramos estão na ordem certa?
- Toda função de janela tem um alias com significado e um frame explícito onde isso importa?
- Todo
UNION(sem ALL) deveria mesmo remover duplicatas? - Todas as colunas estão qualificadas com um alias de tabela?
Para as convenções por trás dessas dicas, veja o guia de estilo de SQL. Se as suas consultas usam recursos do PostgreSQL como casts com :: ou corpos de função com dollar quoting, o guia de formatação para PostgreSQL trata deles, e erros comuns de legibilidade em SQL lista padrões que vale a pena corrigir antes de formatar.