PostgreSQL: Funções de Agregação e Agrupamento no PostgreSQL

Última atualização: 2026-08-26

1. O Que Você Vai Aprender


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:

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.

SQL
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;
TEXT 📖 Somente leitura
 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)

SQL
SELECT
  COUNT(*)       AS all_rows,
  COUNT(discount) AS rows_with_discount
FROM orders;
TEXT 📖 Somente leitura
 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

SQL
SELECT
  SUM(discount)  AS total_discount,
  AVG(discount)  AS avg_discount
FROM orders
WHERE region = 'NA';
TEXT 📖 Somente leitura
 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

SQL
SELECT
  MAX(created_at) AS latest_order,
  MIN(created_at) AS earliest_order
FROM orders;
TEXT 📖 Somente leitura
     latest_order      |    earliest_order
------------------------+------------------------
 2025-12-28 15:30:00   | 2025-01-03 09:12:00

▶ Exemplo: Agregando um Conjunto de Resultados Vazio

SQL
SELECT
  COUNT(*)  AS cnt,
  SUM(amount) AS total
FROM orders
WHERE region = 'ANTARCTICA';
TEXT 📖 Somente leitura
 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.

SQL
SELECT
  region,
  COUNT(*)    AS order_count,
  SUM(amount) AS total_amount
FROM orders
GROUP BY region;
TEXT 📖 Somente leitura
 region | order_count | total_amount
--------+-------------+--------------
 EU     |          35 |      4200000
 NA     |          45 |      5800000
 APAC   |          20 |      2500000

▶ Exemplo: GROUP BY com Múltiplas Colunas

SQL
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;
TEXT 📖 Somente leitura
 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

SQL
SELECT
  region,
  SUM(amount) AS total_amount
FROM orders
GROUP BY region
HAVING SUM(amount) > 3000000
ORDER BY total_amount DESC;
TEXT 📖 Somente leitura
 region | total_amount
--------+--------------
 NA     |      5800000
 EU     |      4200000

O total de 2.500.000 do APAC é filtrado.

▶ Exemplo: Combinação WHERE + HAVING

SQL
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:

TEXT 📖 Somente leitura
 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

SQL
SELECT
  COUNT(DISTINCT customer_id) AS unique_customers,
  COUNT(*)                    AS total_orders
FROM orders;
TEXT 📖 Somente leitura
 unique_customers | total_orders
------------------+--------------
              780 |          1000

▶ Exemplo: SUM DISTINCT para Evitar Contagem Dupla

SQL
SELECT
  SUM(DISTINCT bonus) AS unique_bonus_total
FROM employee_targets;

Output:

TEXT 📖 Somente leitura
  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.

SQL
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

SQL
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:

TEXT 📖 Somente leitura
 count 
-------
     5
(1 row)

▶ Exemplo: FILTER para Comparação Ano a Ano

SQL
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:

TEXT 📖 Somente leitura
  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

SQL
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;
TEXT 📖 Somente leitura
 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

SQL
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:

TEXT 📖 Somente leitura
  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

SQL
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:

TEXT 📖 Somente leitura
  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

SQL
SELECT
  category,
  SUM(amount) AS total_amount
FROM orders
GROUP BY category
ORDER BY total_amount DESC
LIMIT 3;

Output:

TEXT 📖 Somente leitura
  result  
----------
   42.50
(1 row)

▶ Exemplo: Taxa de Retenção de Clientes por Região

SQL
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:

TEXT 📖 Somente leitura
 count 
-------
     5
(1 row)

▶ Exemplo: Tendência Mensal Ano a Ano

SQL
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:

TEXT 📖 Somente leitura
  result  
----------
   42.50
(1 row)

▶ Exemplo: Relatório de Vendas Multidimensional (ROLLUP + FILTER)

SQL
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:

TEXT 📖 Somente leitura
 count 
-------
     5
(1 row)

▶ Exemplo: Filtrando Clientes de Alta Frequência após Agrupamento

SQL
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:

TEXT 📖 Somente leitura
 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:

SQL
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;
TEXT 📖 Somente leitura
 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:

100%
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


📝 Exercícios

  1. ⭐ Conte o número de pedidos e o valor total para cada região na tabela orders, ordenado por valor decrescente.
  2. ⭐ Encontre os valores de customer_id com mais de 10 pedidos e um valor médio acima de 50.000 USD.
  3. ⭐⭐ Usando a cláusula FILTER, produza as vendas trimestrais Q1–Q4 de cada região em uma única instrução SQL.
  4. ⭐⭐ Use CUBE para calcular um resumo cruzado de dimensão completa sobre (region, category) e use GROUPING() para marcar as linhas de resumo.
  5. ⭐⭐⭐ 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.
Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%