Por que GROUP BY e HAVING são centrais em relatórios
Em relatórios do dia a dia, raramente interessa olhar linha a linha. O que normalmente se quer é comparar segmentos: por loja, por categoria, por vendedor, por mês, por canal, por faixa de preço, por status do cliente. É aí que entram GROUP BY e HAVING: eles permitem transformar dados detalhados em uma visão resumida por grupos e, em seguida, aplicar critérios de corte (thresholds) para destacar apenas os grupos relevantes.
Você pode pensar em dois passos: (1) segmentar (agrupar) e calcular métricas por segmento; (2) filtrar segmentos com base nessas métricas (por exemplo, “somente categorias com receita acima de 50 mil” ou “somente vendedores com taxa de devolução maior que 3%”). O primeiro passo é o GROUP BY; o segundo é o HAVING.
O que exatamente o GROUP BY faz
GROUP BY define quais colunas (ou expressões) formam um grupo. Para cada grupo, você calcula métricas agregadas (como total, média, contagem, mínimo, máximo). O resultado deixa de ter uma linha por evento (por exemplo, uma venda) e passa a ter uma linha por segmento (por exemplo, uma loja em um mês).
Regra prática: o que pode aparecer no SELECT quando há GROUP BY
Quando você usa GROUP BY, o SELECT normalmente pode conter:
- As colunas (ou expressões) que estão no GROUP BY (as “chaves do grupo”).
- Expressões agregadas (por exemplo, SUM(...), COUNT(...), AVG(...), MIN(...), MAX(...)).
Se você tentar colocar no SELECT uma coluna que não está no GROUP BY e não está agregada, o banco não sabe qual valor escolher dentro do grupo (porque há várias linhas). Alguns bancos até permitem com regras específicas, mas em relatórios isso costuma gerar ambiguidade e resultados difíceis de justificar.
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
Exemplo: segmentar receita por categoria
SELECT p.categoria, SUM(i.valor_total) AS receita_categoria FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria;Aqui, cada categoria vira uma linha, e a métrica é a soma do valor total dos itens.
O que o HAVING faz (e por que ele não é “um WHERE depois”)
HAVING filtra grupos após as agregações terem sido calculadas. Ele existe porque WHERE filtra linhas antes do agrupamento, e portanto não consegue filtrar com base em métricas agregadas do grupo (como SUM, COUNT, AVG).
Diferença essencial: WHERE vs HAVING
- WHERE: filtra linhas individuais antes do GROUP BY (ex.: “considerar apenas pedidos pagos”).
- HAVING: filtra grupos depois do GROUP BY (ex.: “manter apenas categorias com receita > 50.000”).
Exemplo: categorias com receita acima de um corte
SELECT p.categoria, SUM(i.valor_total) AS receita_categoria FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria HAVING SUM(i.valor_total) > 50000;O corte (50.000) é aplicado sobre a soma por categoria, então precisa estar no HAVING.
Ordem mental de execução (para evitar erros de lógica)
Sem entrar em detalhes internos do otimizador, uma forma útil de raciocinar é:
- 1) FROM / JOIN: monta a tabela “virtual” com as colunas necessárias.
- 2) WHERE: remove linhas que não atendem aos critérios linha a linha.
- 3) GROUP BY: forma os grupos.
- 4) Agregações: calcula SUM/COUNT/AVG etc. por grupo.
- 5) HAVING: remove grupos que não atendem aos critérios agregados.
- 6) SELECT: projeta as colunas finais.
- 7) ORDER BY: ordena o resultado final.
Essa ordem mental ajuda a decidir: “isso é filtro de linha (WHERE) ou filtro de grupo (HAVING)?”
Segmentação em relatórios: padrões que você vai usar sempre
1) Segmentação simples por uma dimensão
Exemplo: total de pedidos por canal.
SELECT canal, COUNT(*) AS qtd_pedidos FROM pedidos GROUP BY canal;Esse padrão é a base para relatórios de distribuição: “como está dividido?”.
2) Segmentação por múltiplas dimensões (cubo básico)
Exemplo: receita por mês e por canal. Aqui você cria uma matriz (mês x canal).
SELECT DATE_TRUNC('month', data_pedido) AS mes, canal, SUM(valor_pedido) AS receita FROM pedidos GROUP BY DATE_TRUNC('month', data_pedido), canal;Note que a expressão usada no SELECT (DATE_TRUNC...) precisa aparecer no GROUP BY (ou você usa um alias se o banco permitir referenciar alias no GROUP BY, o que varia).
3) Segmentação com “faixas” (bins) usando CASE
Relatórios frequentemente pedem faixas: ticket médio por faixa de valor, clientes por faixa de recência, produtos por faixa de margem. Você cria a faixa com CASE e agrupa por ela.
SELECT CASE WHEN valor_pedido < 100 THEN '0-99' WHEN valor_pedido < 300 THEN '100-299' WHEN valor_pedido < 600 THEN '300-599' ELSE '600+' END AS faixa_valor, COUNT(*) AS qtd_pedidos, SUM(valor_pedido) AS receita FROM pedidos GROUP BY CASE WHEN valor_pedido < 100 THEN '0-99' WHEN valor_pedido < 300 THEN '100-299' WHEN valor_pedido < 600 THEN '300-599' ELSE '600+' END;Esse padrão é excelente para segmentar e comparar comportamento por faixas. Em relatórios, ele costuma ser mais útil do que listar valores contínuos.
Critérios de corte: como definir e aplicar com HAVING
Critérios de corte são regras para reduzir o relatório ao que é relevante. Exemplos típicos:
- “Somente segmentos com volume mínimo” (evitar ruído estatístico).
- “Somente segmentos com receita acima de X” (foco em impacto).
- “Somente segmentos com taxa acima de Y” (alertas e exceções).
O HAVING é o lugar natural para esses cortes quando eles dependem de agregações.
Corte por volume mínimo (evitar segmentos com poucas observações)
Exemplo: listar apenas categorias com pelo menos 200 itens vendidos.
SELECT p.categoria, COUNT(*) AS itens_vendidos, SUM(i.valor_total) AS receita FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria HAVING COUNT(*) >= 200;Esse tipo de corte é importante para evitar conclusões com base em amostras pequenas (por exemplo, uma categoria com 2 vendas e uma taxa de devolução de 50%).
Corte por receita mínima e ordenação por impacto
SELECT p.categoria, SUM(i.valor_total) AS receita FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria HAVING SUM(i.valor_total) >= 50000 ORDER BY receita DESC;Note que o ORDER BY usa a métrica final para priorizar o que mais importa no topo do relatório.
Corte por taxa (métrica derivada) com cuidado
Taxas são comuns: taxa de devolução, taxa de cancelamento, taxa de recompra. Em SQL, você normalmente calcula taxa como razão entre duas agregações. O HAVING pode filtrar por essa taxa.
SELECT p.categoria, SUM(CASE WHEN i.devolvido = 1 THEN 1 ELSE 0 END) AS itens_devolvidos, COUNT(*) AS itens_vendidos, 1.0 * SUM(CASE WHEN i.devolvido = 1 THEN 1 ELSE 0 END) / COUNT(*) AS taxa_devolucao FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria HAVING 1.0 * SUM(CASE WHEN i.devolvido = 1 THEN 1 ELSE 0 END) / COUNT(*) > 0.03;Dois pontos práticos: (1) use um fator como 1.0 para forçar divisão em ponto flutuante em bancos que fariam divisão inteira; (2) combine com um corte de volume mínimo para evitar taxa “explodindo” em grupos pequenos.
SELECT p.categoria, COUNT(*) AS itens_vendidos, 1.0 * SUM(CASE WHEN i.devolvido = 1 THEN 1 ELSE 0 END) / COUNT(*) AS taxa_devolucao FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria HAVING COUNT(*) >= 200 AND 1.0 * SUM(CASE WHEN i.devolvido = 1 THEN 1 ELSE 0 END) / COUNT(*) > 0.03;Passo a passo prático: construindo um relatório segmentado com cortes
A seguir, um roteiro que você pode aplicar sempre que precisar criar um relatório com segmentação e critérios de corte. O exemplo será “desempenho por vendedor no mês”, com cortes para destacar vendedores relevantes.
Passo 1: defina a granularidade do relatório (qual é a linha final?)
Decida qual será a unidade do relatório: “uma linha por vendedor no mês”. Isso implica que o GROUP BY terá vendedor e mês.
Passo 2: selecione as dimensões (colunas de segmentação)
Dimensões: vendedor_id (ou nome) e mês.
SELECT vendedor_id, DATE_TRUNC('month', data_pedido) AS mes FROM pedidosNão execute ainda; é só para visualizar as dimensões.
Passo 3: escolha as métricas (agregações) que respondem à pergunta
Exemplos de métricas úteis por vendedor no mês:
- qtd_pedidos
- receita
- ticket_medio (receita / qtd_pedidos)
SELECT vendedor_id, DATE_TRUNC('month', data_pedido) AS mes, COUNT(*) AS qtd_pedidos, SUM(valor_pedido) AS receita, 1.0 * SUM(valor_pedido) / COUNT(*) AS ticket_medio FROM pedidos GROUP BY vendedor_id, DATE_TRUNC('month', data_pedido);Passo 4: aplique filtros de linha no WHERE (escopo do relatório)
Agora defina o escopo: por exemplo, considerar apenas pedidos com status “pago” e um intervalo de datas. Isso é WHERE, porque filtra linhas antes de agrupar.
SELECT vendedor_id, DATE_TRUNC('month', data_pedido) AS mes, COUNT(*) AS qtd_pedidos, SUM(valor_pedido) AS receita, 1.0 * SUM(valor_pedido) / COUNT(*) AS ticket_medio FROM pedidos WHERE status_pagamento = 'pago' AND data_pedido >= DATE '2025-01-01' AND data_pedido < DATE '2025-04-01' GROUP BY vendedor_id, DATE_TRUNC('month', data_pedido);Passo 5: aplique critérios de corte no HAVING (relevância)
Suponha que você quer ver apenas vendedores com pelo menos 50 pedidos no mês e receita acima de 30.000. Esses cortes dependem das agregações, então vão em HAVING.
SELECT vendedor_id, DATE_TRUNC('month', data_pedido) AS mes, COUNT(*) AS qtd_pedidos, SUM(valor_pedido) AS receita, 1.0 * SUM(valor_pedido) / COUNT(*) AS ticket_medio FROM pedidos WHERE status_pagamento = 'pago' AND data_pedido >= DATE '2025-01-01' AND data_pedido < DATE '2025-04-01' GROUP BY vendedor_id, DATE_TRUNC('month', data_pedido) HAVING COUNT(*) >= 50 AND SUM(valor_pedido) >= 30000;Esse padrão é o coração de relatórios “executivos”: ele remove cauda longa e deixa o que merece atenção.
Passo 6: ordene para leitura (ORDER BY) e, se necessário, limite
SELECT vendedor_id, DATE_TRUNC('month', data_pedido) AS mes, COUNT(*) AS qtd_pedidos, SUM(valor_pedido) AS receita, 1.0 * SUM(valor_pedido) / COUNT(*) AS ticket_medio FROM pedidos WHERE status_pagamento = 'pago' AND data_pedido >= DATE '2025-01-01' AND data_pedido < DATE '2025-04-01' GROUP BY vendedor_id, DATE_TRUNC('month', data_pedido) HAVING COUNT(*) >= 50 AND SUM(valor_pedido) >= 30000 ORDER BY mes, receita DESC;Ordenar por mês e receita desc cria uma leitura natural: dentro de cada mês, quem mais impacta aparece primeiro.
Erros comuns e como evitá-los em relatórios com GROUP BY/HAVING
1) Colocar condição agregada no WHERE
Um erro frequente é tentar fazer:
SELECT categoria, SUM(valor_total) AS receita FROM itens_pedido GROUP BY categoria WHERE SUM(valor_total) > 50000;Isso não funciona porque WHERE acontece antes do agrupamento. A condição de soma precisa ir no HAVING.
2) Filtrar cedo demais e mudar o significado do relatório
Às vezes o filtro é de linha, mas altera o que você quer medir. Exemplo: você quer “receita total por categoria” e “taxa de devolução por categoria”. Se você filtra no WHERE apenas devolvidos, sua receita vira “receita de itens devolvidos”, não a receita total. O padrão correto é manter o escopo no WHERE (por exemplo, período e status) e usar CASE dentro das agregações para contar devoluções sem excluir o restante.
SELECT p.categoria, SUM(i.valor_total) AS receita_total, SUM(CASE WHEN i.devolvido = 1 THEN i.valor_total ELSE 0 END) AS receita_devolvida, 1.0 * SUM(CASE WHEN i.devolvido = 1 THEN 1 ELSE 0 END) / COUNT(*) AS taxa_devolucao FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id WHERE i.data_item >= DATE '2025-01-01' AND i.data_item < DATE '2025-04-01' GROUP BY p.categoria;3) Usar HAVING para filtros que deveriam ser WHERE
Você até pode escrever filtros de linha no HAVING em alguns bancos, mas isso costuma ser menos eficiente e menos claro. Se o filtro não depende de agregação (por exemplo, “canal = 'online'”), ele deve estar no WHERE.
4) Cortes que escondem segmentos importantes
Um corte mal escolhido pode remover justamente o que você precisava ver. Exemplo: “somente categorias com receita > 50.000” pode esconder uma categoria pequena com margem alta ou crescimento acelerado. Uma prática útil é criar relatórios alternativos:
- Relatório A: corte por impacto (receita).
- Relatório B: corte por variação (crescimento) com volume mínimo.
O SQL muda pouco: você troca o HAVING e o ORDER BY para refletir o objetivo.
Padrões avançados úteis para segmentação e cortes
Top N por grupo (ranking) com GROUP BY + janela
Às vezes você quer “top 3 produtos por categoria” ou “top 5 vendedores por mês”. Isso exige ranking por grupo. Um caminho comum é: primeiro agregar (GROUP BY), depois ranquear com função de janela, e por fim filtrar. Como o filtro do ranking acontece após o cálculo do ranking, ele costuma ser feito em uma subconsulta.
SELECT * FROM ( SELECT p.categoria, i.produto_id, SUM(i.valor_total) AS receita_produto, ROW_NUMBER() OVER (PARTITION BY p.categoria ORDER BY SUM(i.valor_total) DESC) AS rn FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria, i.produto_id) t WHERE t.rn <= 3;Note que o GROUP BY cria a receita por produto dentro da categoria; a janela cria o ranking; o WHERE externo aplica o corte “top 3”.
Segmentação com múltiplos níveis: subtotal e total (ROLLUP)
Alguns bancos suportam extensões como ROLLUP para gerar subtotais automaticamente. Isso é útil quando você quer, por exemplo, receita por canal e por mês, mais o total do mês e o total geral.
SELECT DATE_TRUNC('month', data_pedido) AS mes, canal, SUM(valor_pedido) AS receita FROM pedidos GROUP BY ROLLUP (DATE_TRUNC('month', data_pedido), canal) ORDER BY mes, canal;O resultado inclui linhas onde canal é nulo (subtotal do mês) e linhas onde mes e canal são nulos (total geral). Em relatórios, isso pode economizar consultas separadas, mas exige cuidado na apresentação (por exemplo, substituir nulos por rótulos no consumo do relatório).
Cortes dinâmicos por percentil (quando “acima de X” não é estável)
Em alguns cenários, um corte fixo (por exemplo, receita > 50.000) não funciona bem porque o volume varia ao longo do tempo. Uma alternativa é cortar por percentil: “top 10% dos segmentos”. Isso normalmente envolve calcular a métrica por grupo e depois aplicar uma função de janela como NTILE. Exemplo: separar categorias em decis (10 grupos) por receita e manter apenas o decil 1 (top 10%).
SELECT * FROM ( SELECT categoria, receita, NTILE(10) OVER (ORDER BY receita DESC) AS decil FROM ( SELECT p.categoria AS categoria, SUM(i.valor_total) AS receita FROM itens_pedido i JOIN produtos p ON p.produto_id = i.produto_id GROUP BY p.categoria ) a) b WHERE b.decil = 1;Esse padrão é útil quando você quer um relatório sempre “do mesmo tamanho” e comparável mês a mês, mesmo com variações de escala.