GROUP BY, HAVING e agregações que resolvem 80% dos casos
Agregações são o que transformam linhas individuais em métricas de negócio. COUNT, SUM, AVG com GROUP BY resolvem a maioria dos relatórios — e FILTER, ROLLUP e DISTINCT ON resolvem os casos que parecem precisar de subqueries complicadas.
Funções de agregação essenciais
-- Funções de agregação básicas
SELECT
COUNT(*) AS total_pedidos, -- conta linhas (inclui NULL)
COUNT(DISTINCT cliente_id) AS clientes_unicos, -- conta valores distintos
SUM(total) AS receita_total,
AVG(total) AS ticket_medio,
MIN(total) AS menor_pedido,
MAX(total) AS maior_pedido,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total) AS mediana,
STDDEV(total) AS desvio_padrao
FROM pedidos
WHERE status = 'concluido';
-- GROUP BY: agrupa linhas e aplica agregação por grupo
SELECT
DATE_TRUNC('month', criado_em) AS mes,
status,
COUNT(*) AS num_pedidos,
SUM(total) AS receita
FROM pedidos
GROUP BY DATE_TRUNC('month', criado_em), status
ORDER BY mes DESC, status;
-- Regra do GROUP BY: SELECT pode ter apenas
-- ✅ Colunas do GROUP BY
-- ✅ Funções de agregação
-- ❌ Outras colunas (erro: "must appear in GROUP BY clause")
-- HAVING: filtra após agregação
SELECT
cliente_id,
COUNT(*) AS num_pedidos,
SUM(total) AS total_gasto
FROM pedidos
WHERE criado_em >= '2024-01-01' -- filtra ANTES de agrupar
GROUP BY cliente_id
HAVING SUM(total) > 1000 -- filtra DEPOIS de agrupar
ORDER BY total_gasto DESC
LIMIT 20;FILTER: agregações condicionais numa passagem
| Objetivo | Forma ingênua | Forma em uma passagem |
|---|---|---|
| Contar total e um subconjunto | Duas consultas e junção | Duas agregações com filtro condicional na mesma leitura |
| Percentual sobre o total | Subconsulta para o denominador | Função de janela sobre o resultado agregado |
| Média excluindo zeros | Filtro no WHERE — que remove a linha do total | Filtro dentro da agregação, preservando as demais |
| Primeiro e último por grupo | Junção com máximo por grupo | Função de janela com ordenação |
| ⚠ HAVING versus WHERE | Filtrar antes ou depois de agrupar | WHERE reduz o que é agregado; HAVING filtra o resultado |
-- Relatório de pedidos por status — SEM FILTER (3 subqueries ou pivô manual)
SELECT
COUNT(*) FILTER (WHERE status = 'pendente') AS pendentes,
COUNT(*) FILTER (WHERE status = 'processando') AS processando,
COUNT(*) FILTER (WHERE status = 'concluido') AS concluidos,
COUNT(*) FILTER (WHERE status = 'cancelado') AS cancelados,
SUM(total) FILTER (WHERE status = 'concluido') AS receita_realizada,
SUM(total) FILTER (WHERE status = 'pendente') AS receita_potencial
FROM pedidos
WHERE criado_em >= NOW() - INTERVAL '30 days';
-- Alternativa menos legível (CASE WHEN dentro de agregação):
SELECT
COUNT(CASE WHEN status = 'pendente' THEN 1 END) AS pendentes,
SUM(CASE WHEN status = 'concluido' THEN total ELSE 0 END) AS receita
FROM pedidos;
-- Análise de cohort: primeiros 30 dias vs depois
SELECT
DATE_TRUNC('month', primeiro_pedido.data) AS cohort,
COUNT(DISTINCT p.cliente_id) AS total_clientes,
COUNT(DISTINCT p.cliente_id) FILTER (
WHERE p.criado_em <= primeiro_pedido.data + INTERVAL '30 days'
) AS compraram_no_primeiro_mes
FROM pedidos p
JOIN (
SELECT cliente_id, MIN(criado_em) AS data FROM pedidos GROUP BY cliente_id
) primeiro_pedido USING (cliente_id)
GROUP BY DATE_TRUNC('month', primeiro_pedido.data)
ORDER BY cohort;Qual é a diferença entre `WHERE` e `HAVING`?
ROLLUP, CUBE e GROUPING SETS
-- ROLLUP: subtotais hierárquicos
SELECT
COALESCE(DATE_TRUNC('year', criado_em)::TEXT, 'TOTAL') AS ano,
COALESCE(DATE_TRUNC('month', criado_em)::TEXT, 'SUBTOTAL') AS mes,
COUNT(*) AS pedidos,
SUM(total) AS receita
FROM pedidos
GROUP BY ROLLUP(
DATE_TRUNC('year', criado_em),
DATE_TRUNC('month', criado_em)
)
ORDER BY ano NULLS LAST, mes NULLS LAST;
-- Resultado:
-- 2024-01-01 | 2024-01-01 | 150 | 45000 ← janeiro 2024
-- 2024-01-01 | 2024-02-01 | 130 | 39000 ← fevereiro 2024
-- 2024-01-01 | NULL | 280 | 84000 ← subtotal 2024 (ROLLUP!)
-- NULL | NULL | 280 | 84000 ← grande total (ROLLUP!)
-- GROUPING SETS: especifica exatamente os agrupamentos que quer
SELECT
produto,
regiao,
SUM(vendas) AS total
FROM fatos_vendas
GROUP BY GROUPING SETS (
(produto, regiao), -- por produto + região
(produto), -- só por produto
(regiao), -- só por região
() -- grande total
);
-- DISTINCT ON: primeiro registro de cada grupo (PostgreSQL específico)
SELECT DISTINCT ON (cliente_id)
cliente_id,
criado_em,
total
FROM pedidos
ORDER BY cliente_id, criado_em DESC; -- pega o pedido mais recente por cliente
-- Agregações de texto (string_agg é muito útil)
SELECT
pedido_id,
STRING_AGG(produto, ', ' ORDER BY produto) AS produtos_lista
FROM itens_pedido
GROUP BY pedido_id;Padrões essenciais: use FILTER (WHERE ...) para múltiplas agregações condicionais em vez de várias subqueries. Use DISTINCT ON (coluna) para o primeiro/último de cada grupo em vez de window function + subquery. Use ROLLUP para relatórios com subtotais automáticos. Sempre filtre com WHERE antes de agregar — é mais eficiente que HAVING.
Próximo: Window functions — ROW_NUMBER, RANK, LAG, LEAD, e SUM OVER para cálculos que precisam ver outras linhas sem GROUP BY.
Perguntas frequentes
❓ Qual a diferença entre WHERE e HAVING?
❓ Por que a contagem ignora nulos?
❓ Como somar por vários níveis na mesma consulta?
Fixando
Você precisa contar, na mesma consulta, o total de pedidos e quantos foram cancelados. Qual abordagem é a mais eficiente?
Para que servem os agrupamentos múltiplos, como totais parciais e subtotais numa única consulta?
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…