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:

Antes
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;
Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: não
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. Evite cte1, tmp ou base.
  • 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_revenue para 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:

Antes
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;
Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: não
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:

Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: sim
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:

Antes
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)
Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: não
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):

Antes
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;
Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: não
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, >= 1000 precisa 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, escreva ELSE NULL para 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:

Antes
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';
Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: não
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ão rn ou sum1.
  • 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 BY e ORDER BY, os bancos que suportam a cláusula WINDOW nomeada, 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 WHERE do 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:

Antes
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;
Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: não
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 UNION remove 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_type para 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.