PostgreSQL: Funções de Agregação e Agrupamento no PostgreSQL
Última atualização: 2026-08-26
1. O Que Você Vai Aprender
- As cinco funções de agregação principais: COUNT / SUM / AVG / MAX / MIN
- GROUP BY para agrupamento por coluna única e múltiplas colunas
- Cláusula HAVING para filtrar resultados agrupados
- Recursos do PostgreSQL: GROUPING SETS / ROLLUP / CUBE, agrupamento multidimensional
- Recurso do PostgreSQL: cláusula FILTER para agregação condicional
- Uso de DISTINCT dentro de agregações
- Como as funções de agregação tratam NULL
2. A História
Bob é um analista de dados em uma plataforma de e-commerce internacional. Com a aproximação do fim do ano, o CEO pede que ele produza um relatório anual de análise de vendas:
- Contar pedidos e receita de vendas por região
- Análise cruzada em múltiplas dimensões (trimestre + região)
- Calcular a diferença de receita entre meses com e sem pedidos
- Produzir dados resumidos para todas as dimensões de uma só vez
Bob descobre que um GROUP BY simples só pode agrupar por uma dimensão por vez, forçando-o a escrever múltiplas instruções SQL e uni-las com UNION. Até que ele aprende as cláusulas GROUPING SETS e FILTER do PostgreSQL, que permitem que uma única instrução SQL atenda a todos os requisitos.
3. Conceito
(1) Visão Geral das Funções de Agregação
Funções de agregação colapsam múltiplas linhas de entrada em uma única linha de saída e são a pedra angular da análise de dados.
SELECT
COUNT(*) AS total_rows,
COUNT(amount) AS non_null_count,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount,
MAX(amount) AS max_amount,
MIN(amount) AS min_amount
FROM orders;
total_rows | non_null_count | total_amount | avg_amount | max_amount | min_amount
------------+----------------+--------------+------------+------------+------------
100 | 95 | 12500000 | 131578.95 | 500000 | 1200
(2) As Cinco Funções de Agregação em Detalhe
| Função | Finalidade | Comportamento com NULL | Tipo de retorno |
|---|---|---|---|
COUNT(*) |
Contar todas as linhas (incl. NULL) | Inclui NULL | bigint |
COUNT(col) |
Contar linhas não NULL | Ignora NULL | bigint |
SUM(col) |
Soma | Ignora NULL; tudo NULL retorna NULL | Igual à entrada |
AVG(col) |
Média | Ignora NULL | numeric |
MAX(col) / MIN(col) |
Valor máximo / mínimo | Ignora NULL | Igual à entrada |
▶ Exemplo: COUNT(*) vs COUNT(col)
SELECT
COUNT(*) AS all_rows,
COUNT(discount) AS rows_with_discount
FROM orders;
all_rows | rows_with_discount
----------+-------------------
100 | 42
58 linhas têm desconto NULL, então COUNT(discount) as exclui.
▶ Exemplo: Tratamento de NULL no SUM e AVG
SELECT
SUM(discount) AS total_discount,
AVG(discount) AS avg_discount
FROM orders
WHERE region = 'NA';
total_discount | avg_discount
----------------+--------------------
125000 | 2976.1904761904762
AVG calcula a média apenas das linhas não NULL: 125000 / 42 ≈ 2976,19, não 125000 / 100.
▶ Exemplo: MAX/MIN para Extremos
SELECT
MAX(created_at) AS latest_order,
MIN(created_at) AS earliest_order
FROM orders;
latest_order | earliest_order
------------------------+------------------------
2025-12-28 15:30:00 | 2025-01-03 09:12:00
▶ Exemplo: Agregando um Conjunto de Resultados Vazio
SELECT
COUNT(*) AS cnt,
SUM(amount) AS total
FROM orders
WHERE region = 'ANTARCTICA';
cnt | total
-----+-------
0 |
COUNT retorna 0 para um conjunto vazio, enquanto SUM retorna NULL para um conjunto vazio — uma armadilha clássica.
(3) GROUP BY Agrupamento
GROUP BY agrupa linhas pela(s) coluna(s) especificada(s), produzindo uma linha agregada por grupo.
SELECT
region,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
GROUP BY region;
region | order_count | total_amount
--------+-------------+--------------
EU | 35 | 4200000
NA | 45 | 5800000
APAC | 20 | 2500000
▶ Exemplo: GROUP BY com Múltiplas Colunas
SELECT
region,
EXTRACT(QUARTER FROM created_at)::int AS quarter,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
GROUP BY region, EXTRACT(QUARTER FROM created_at)
ORDER BY region, quarter;
region | quarter | order_count | total_amount
--------+---------+-------------+--------------
APAC | 1 | 5 | 620000
APAC | 2 | 6 | 780000
APAC | 3 | 4 | 500000
APAC | 4 | 5 | 600000
EU | 1 | 8 | 950000
EU | 2 | 9 | 1100000
...
(4) HAVING Filtrando Grupos
WHERE filtra linhas antes do agrupamento; HAVING filtra grupos após o agrupamento.
| Cláusula | Quando aplicada | Permite agregações |
|---|---|---|
| WHERE | Antes do GROUP BY | Não |
| HAVING | Após GROUP BY | Sim |
▶ Exemplo: Filtro HAVING para Regiões de Alta Receita
SELECT
region,
SUM(amount) AS total_amount
FROM orders
GROUP BY region
HAVING SUM(amount) > 3000000
ORDER BY total_amount DESC;
region | total_amount
--------+--------------
NA | 5800000
EU | 4200000
O total de 2.500.000 do APAC é filtrado.
▶ Exemplo: Combinação WHERE + HAVING
SELECT
region,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE amount >= 5000
GROUP BY region
HAVING COUNT(*) >= 10
ORDER BY total_amount DESC;
Output:
count
-------
5
(1 row)
Primeiro os pedidos abaixo de 5.000 são filtrados, depois regiões com menos de 10 pedidos no grupo são removidas.
4. Pontos-Chave
(1) Usando DISTINCT dentro de Agregações
▶ Exemplo: Contando Clientes Distintos
SELECT
COUNT(DISTINCT customer_id) AS unique_customers,
COUNT(*) AS total_orders
FROM orders;
unique_customers | total_orders
------------------+--------------
780 | 1000
▶ Exemplo: SUM DISTINCT para Evitar Contagem Dupla
SELECT
SUM(DISTINCT bonus) AS unique_bonus_total
FROM employee_targets;
Output:
result
----------
42.50
(1 row)
(2) Cláusula FILTER (Recurso do PostgreSQL)
A cláusula FILTER permite agregar o mesmo conjunto de linhas sob diferentes condições sem escrever múltiplas expressões CASE WHEN.
SELECT
region,
COUNT(*) FILTER (WHERE amount >= 10000) AS high_value_orders,
COUNT(*) FILTER (WHERE amount < 10000) AS low_value_orders,
SUM(amount) FILTER (WHERE quarter = 1) AS q1_revenue,
SUM(amount) FILTER (WHERE quarter = 2) AS q2_revenue
FROM orders
GROUP BY region;
| Abordagem | Sintaxe | Legibilidade | Desempenho |
|---|---|---|---|
| CASE WHEN | SUM(CASE WHEN ... THEN x ELSE 0 END) |
Razoável | Uma varredura |
| FILTER | SUM(x) FILTER (WHERE ...) |
Excelente | Uma varredura |
▶ Exemplo: FILTER para Meses Com/Sem Pedidos
SELECT
region,
COUNT(DISTINCT EXTRACT(MONTH FROM created_at))
FILTER (WHERE amount > 0) AS months_with_orders,
12 - COUNT(DISTINCT EXTRACT(MONTH FROM created_at))
FILTER (WHERE amount > 0) AS months_without_orders
FROM orders
WHERE EXTRACT(YEAR FROM created_at) = 2025
GROUP BY region;
Output:
count
-------
5
(1 row)
▶ Exemplo: FILTER para Comparação Ano a Ano
SELECT
region,
SUM(amount) FILTER (WHERE EXTRACT(YEAR FROM created_at) = 2024) AS revenue_2024,
SUM(amount) FILTER (WHERE EXTRACT(YEAR FROM created_at) = 2025) AS revenue_2025
FROM orders
GROUP BY region;
Output:
result
----------
42.50
(1 row)
(3) GROUPING SETS / ROLLUP / CUBE (Recurso do PostgreSQL)
Produz resultados agregados em múltiplas dimensões em uma única consulta, sem necessidade de escrever múltiplas instruções SQL e uni-las com UNION.
▶ Exemplo: GROUPING SETS com Combinações de Dimensão Personalizadas
SELECT
region,
EXTRACT(QUARTER FROM created_at)::int AS quarter,
SUM(amount) AS total_amount
FROM orders
GROUP BY GROUPING SETS (
(region, EXTRACT(QUARTER FROM created_at)),
(region),
(EXTRACT(QUARTER FROM created_at)),
()
)
ORDER BY region NULLS LAST, quarter NULLS LAST;
region | quarter | total_amount
--------+---------+--------------
APAC | 1 | 620000
APAC | 2 | 780000
APAC | 3 | 500000
APAC | 4 | 600000
APAC | | 2500000
EU | 1 | 950000
...
| 1 | 2200000
...
| | 12500000
NULL significa que essa dimensão está no nível de resumo; use a função GROUPING() para distinguir NULLs reais de NULLs de nível de resumo.
▶ Exemplo: ROLLUP Resumo Hierárquico
SELECT
region,
EXTRACT(QUARTER FROM created_at)::int AS quarter,
SUM(amount) AS total_amount,
GROUPING(region) AS g_region,
GROUPING(quarter) AS g_quarter
FROM orders
GROUP BY ROLLUP (region, EXTRACT(QUARTER FROM created_at))
ORDER BY region NULLS LAST, quarter NULLS LAST;
Output:
result
----------
42.50
(1 row)
| Sintaxe | GROUPING SETS equivalente | Dimensões de saída |
|---|---|---|
ROLLUP(a, b) |
(a,b), (a), () |
Hierarquia: detalhe → subtotal → total geral |
CUBE(a, b) |
(a,b), (a), (b), () |
Cruzamento completo: todas as combinações |
GROUPING SETS((a),(b)) |
(a), (b) |
Qualquer combinação personalizada |
▶ Exemplo: CUBE Cruzamento de Dimensão Completa
SELECT
region,
EXTRACT(QUARTER FROM created_at)::int AS quarter,
SUM(amount) AS total_amount
FROM orders
GROUP BY CUBE (region, EXTRACT(QUARTER FROM created_at))
ORDER BY region NULLS LAST, quarter NULLS LAST;
Output:
result
----------
42.50
(1 row)
(4) Resumo do Comportamento de NULL das Funções de Agregação
| Cenário | COUNT(*) | COUNT(col) | SUM | AVG | MAX/MIN |
|---|---|---|---|---|---|
| Tem valores não NULL | Conta todas as linhas | Conta apenas não NULL | Soma ignorando NULL | Média ignorando NULL | Extremos ignorando NULL |
| Tudo NULL | Conta linhas | 0 | NULL | NULL | NULL |
| Conjunto de resultados vazio | 0 | 0 | NULL | NULL | NULL |
5. Prática
▶ Exemplo: Top 3 Vendas por Categoria de Produto
SELECT
category,
SUM(amount) AS total_amount
FROM orders
GROUP BY category
ORDER BY total_amount DESC
LIMIT 3;
Output:
result
----------
42.50
(1 row)
▶ Exemplo: Taxa de Retenção de Clientes por Região
SELECT
region,
COUNT(DISTINCT customer_id) FILTER (
WHERE EXTRACT(YEAR FROM created_at) = 2024
) AS customers_2024,
COUNT(DISTINCT customer_id) FILTER (
WHERE EXTRACT(YEAR FROM created_at) = 2025
) AS customers_2025,
ROUND(
COUNT(DISTINCT customer_id) FILTER (WHERE EXTRACT(YEAR FROM created_at) = 2025)::numeric
/ NULLIF(
COUNT(DISTINCT customer_id) FILTER (WHERE EXTRACT(YEAR FROM created_at) = 2024),
0
) * 100, 1
) AS retention_rate
FROM orders
GROUP BY region;
Output:
count
-------
5
(1 row)
▶ Exemplo: Tendência Mensal Ano a Ano
SELECT
EXTRACT(MONTH FROM created_at)::int AS month_num,
SUM(amount) FILTER (WHERE EXTRACT(YEAR FROM created_at) = 2024) AS revenue_2024,
SUM(amount) FILTER (WHERE EXTRACT(YEAR FROM created_at) = 2025) AS revenue_2025
FROM orders
GROUP BY EXTRACT(MONTH FROM created_at)
ORDER BY month_num;
Output:
result
----------
42.50
(1 row)
▶ Exemplo: Relatório de Vendas Multidimensional (ROLLUP + FILTER)
SELECT
region,
category,
SUM(amount) AS total_amount,
COUNT(*) FILTER (WHERE amount >= 50000) AS big_deals,
GROUPING(region) AS g_region,
GROUPING(category) AS g_category
FROM orders
GROUP BY ROLLUP (region, category)
ORDER BY region NULLS LAST, category NULLS LAST;
Output:
count
-------
5
(1 row)
▶ Exemplo: Filtrando Clientes de Alta Frequência após Agrupamento
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id
HAVING COUNT(*) >= 5 AND SUM(amount) >= 100000
ORDER BY total_spent DESC;
Output:
count
-------
5
(1 row)
6. Exemplo Abrangente
A análise anual de vendas de Bob — uma instrução SQL produzindo um relatório de dimensão completa:
SELECT
region,
EXTRACT(QUARTER FROM created_at)::int AS quarter,
SUM(amount) AS total_revenue,
COUNT(*) AS order_count,
COUNT(DISTINCT customer_id) AS unique_customers,
SUM(amount) FILTER (WHERE amount >= 50000) AS big_deal_revenue,
COUNT(*) FILTER (WHERE amount >= 50000) AS big_deal_count,
AVG(amount) AS avg_order_value,
GROUPING(region) AS g_region,
GROUPING(quarter) AS g_quarter
FROM orders
WHERE EXTRACT(YEAR FROM created_at) = 2025
GROUP BY ROLLUP (region, EXTRACT(QUARTER FROM created_at))
ORDER BY region NULLS LAST, quarter NULLS LAST;
region | quarter | total_revenue | order_count | unique_customers | big_deal_revenue | big_deal_count | avg_order_value | g_region | g_quarter
--------+---------+---------------+-------------+------------------+------------------+----------------+-----------------+----------+-----------
APAC | 1 | 620000 | 5 | 4 | 120000 | 1 | 124000.00 | 0 | 0
APAC | 2 | 780000 | 6 | 5 | 250000 | 2 | 130000.00 | 0 | 0
APAC | 3 | 500000 | 4 | 3 | 50000 | 1 | 125000.00 | 0 | 0
APAC | 4 | 600000 | 5 | 4 | 100000 | 1 | 120000.00 | 0 | 0
APAC | | 2500000 | 20 | 12 | 520000 | 5 | 125000.00 | 0 | 1
EU | 1 | 950000 | 8 | 7 | 350000 | 3 | 118750.00 | 0 | 0
...
| | 12500000 | 100 | 780 | 5000000 | 45 | 125000.00 | 1 | 1
7. Fluxo de Execução
A ordem completa de execução de uma consulta de agregação:
flowchart TD
A[FROM] --> B[WHERE]
B --> C[GROUP BY]
C --> D[HAVING]
D --> E["Funções de Agregação<br/>COUNT/SUM/AVG/MAX/MIN"]
E --> F[SELECT]
F --> G[ORDER BY]
G --> H[LIMIT]
style A fill:#e1f5fe
style C fill:#fff9c4
style D fill:#fff9c4
style E fill:#c8e6c9
| Etapa | Cláusula | Descrição |
|---|---|---|
| 1 | FROM | Determinar a fonte de dados |
| 2 | WHERE | Filtrar linhas (antes do agrupamento) |
| 3 | GROUP BY | Agrupar linhas |
| 4 | Funções de agregação | Calcular agregação para cada grupo |
| 5 | HAVING | Filtrar grupos (após o agrupamento) |
| 6 | SELECT | Selecionar colunas de saída |
| 7 | ORDER BY | Ordenar |
| 8 | LIMIT | Limitar o número de linhas |
❓ Perguntas Frequentes
P: Existe diferença entre COUNT(*) e COUNT(1)? R: Eles são completamente equivalentes no PostgreSQL. COUNT(*) é a forma recomendada, pois seu significado é mais claro.
P: Por que SUM retorna NULL em vez de 0 para um grupo vazio? R: O padrão SQL estabelece que SUM sobre entrada toda NULL ou vazia retorna NULL. Se você precisa de 0, use COALESCE(SUM(col), 0).
P: HAVING pode ser usado sem uma função de agregação? R: Sim — HAVING region = 'NA' é sintaticamente válido, mas tal condição pertence ao WHERE para melhor desempenho.
P: A cláusula FILTER é tão rápida quanto CASE WHEN? R: Essencialmente igual — ambas varrem os dados apenas uma vez. FILTER é mais legível e é a forma recomendada pelo PostgreSQL.
P: Como GROUPING SETS difere de múltiplas instruções SQL com UNION? R: GROUPING SETS varre a tabela apenas uma vez, enquanto múltiplas instruções SQL com UNION a varrem várias vezes. A diferença de desempenho é significativa em grandes volumes de dados.
P: Colunas não agrupadas podem aparecer no SELECT após GROUP BY? R: Não no modo estrito do PostgreSQL. Qualquer coluna não agregada no SELECT também deve aparecer no GROUP BY, ou ocorre erro.
📖 Resumo
- As cinco funções de agregação COUNT/SUM/AVG/MAX/MIN tratam NULL de forma diferente
- GROUP BY agrupa por coluna; HAVING filtra resultados agrupados
- WHERE filtra linhas antes do agrupamento; HAVING filtra grupos após o agrupamento
- A cláusula FILTER do PostgreSQL substitui CASE WHEN com sintaxe mais limpa
- GROUPING SETS / ROLLUP / CUBE produzem resumos multidimensionais em uma única consulta
- A função GROUPING() distingue NULLs reais de NULLs de nível de resumo
- COUNT(*) retorna 0 para um conjunto vazio; SUM/AVG retornam NULL
📝 Exercícios
- ⭐ Conte o número de pedidos e o valor total para cada região na tabela orders, ordenado por valor decrescente.
- ⭐ Encontre os valores de customer_id com mais de 10 pedidos e um valor médio acima de 50.000 USD.
- ⭐⭐ Usando a cláusula FILTER, produza as vendas trimestrais Q1–Q4 de cada região em uma única instrução SQL.
- ⭐⭐ Use CUBE para calcular um resumo cruzado de dimensão completa sobre (region, category) e use GROUPING() para marcar as linhas de resumo.
- ⭐⭐⭐ Escreva uma única instrução SQL que produza: o resumo por região, o resumo por categoria, o detalhe região+categoria e o total geral da tabela — varrendo a tabela orders apenas uma vez.