Agregação: medindo o negócio

COUNT, SUM, AVG: linhas viram números

25 min
O que você vai aprender
  • escrever consultas com COUNT, SUM, AVG, MIN e MAX
  • entender a ideia central dos agregados: muitas linhas se transformam em um único resumo final
  • calcular a quantidade de linhas, a soma, a média, o mínimo e o máximo
  • combinar WHERE com agregados para somar apenas as linhas que interessam
  • diferenciar COUNT(*), COUNT(col) e COUNT(DISTINCT col)
  • explicar por que os agregados costumam ignorar NULL
  • entender o que os agregados retornam quando não sobra nenhuma linha depois do filtro
  • explicar por que SELECT status, COUNT(*) FROM orders sem GROUP BY falha com erro

Adiantando um pouco.

Neste capítulo, alguns exemplos podem unir duas tabelas com JOIN — esse é o tema do Capítulo 4, e vamos explorá-lo em detalhes lá.

Por enquanto, leia uma linha como:

orders o
JOIN order_items oi ON oi.order_id = o.id

simplesmente assim:

as linhas de pedidos foram unidas às linhas de itens do pedido por uma chave em comum.

O que importa agora não são as junções entre tabelas, mas os agregados: como o SQL calcula o total de um conjunto de linhas.

Capítulo 3 — "O Pulso da Loja"

A credencial de "Analista" se acomoda no seu crachá como uma faixa âmbar e morna.

O bloco criptografado de K. nunca chegou a abrir, mas você lembra o título de cor:

Linhas brutas são ruído.
O sentido aparece quando você sabe somá-las.

QUERY apaga os holopainéis extras e deixa apenas um — vazio, com um único ponto pulsando no centro.

QUERY: Você vinha lendo o arquivo linha por linha. Chega. Hoje vamos ouvir o coração dele.

Um cadete diante de um holopainel: um fluxo de linhas-cápsula se comprime em um único ponto pulsante — um número
Um agregado comprime centenas de linhas em uma única batida — um número que dá para dizer em voz alta.

Até agora, você basicamente buscava linhas.

Por exemplo:

SELECT id, user_id, status, total_amount
FROM orders
WHERE status = 'paid';

Uma consulta assim mostra os pedidos pagos um por um:

iduser_idstatustotal_amount
1017paid1200
1029paid3500
1037paid800
10412paid2100

Isso é útil quando você precisa examinar as linhas em si.

Mas uma pergunta de negócio costuma soar diferente:

Quantos pedidos foram pagos?
Qual é a receita total?
Qual é o ticket médio?
Qual foi o menor e o maior pedido?
Quantos compradores diferentes fizeram pedidos?

Perguntas assim não pedem uma lista longa de linhas. Pedem um resumo.

Para isso, o SQL tem as funções de agregação.

Uma função de agregação pega um conjunto de linhas e o condensa em um único valor final.

Por exemplo:

SELECT COUNT(*) AS orders_count
FROM orders;

Essa consulta não mostra cada pedido separadamente. Ela retorna um único número: quantas linhas existem na tabela orders.

Funções de agregação: muitas linhas → um valor

Os agregados transformam um fluxo de linhas em um indicador que dá para dizer em voz alta.

Não:

pedido 101
pedido 102
pedido 103
pedido 104

Mas sim:

4 pedidos ao todo

Não:

1200
3500
800
2100

Mas sim:

soma total 7600
ticket médio 1900
ticket mínimo 800
ticket máximo 3500

Cinco funções de agregação principais:

FunçãoO que calculaExemplo de pergunta
COUNT(*)a quantidade de linhasQuantos pedidos?
SUM(x)a soma dos valoresQual é a receita?
AVG(x)o valor médioQual é o ticket médio?
MIN(x)o valor mínimoQual é o menor ticket?
MAX(x)o valor máximoQual é o maior ticket?

Por exemplo:

SELECT
    COUNT(*) AS orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders;

Se a consulta não tem GROUP BY, os agregados calculam o total sobre todo o conjunto de linhas que chegou até eles.

Ou seja, o resultado será uma única linha:

orders_countrevenueavg_order_amountmin_order_amountmax_order_amount
4760019008003500
9905990129034902790SUM14 550one number
Uma função de agregação comprime um fluxo de linhas em um único resultado — um número para o conjunto inteiro.

COUNT: quantas linhas

COUNT responde à pergunta "quantos?".

A forma mais comum:

COUNT(*)

Ela conta linhas.

Por exemplo:

SELECT COUNT(*) AS orders_count
FROM orders;

Resultado:

orders_count
128

O asterisco em COUNT(*) não significa "selecione todas as colunas". Aqui, ele significa:

conte as próprias linhas.

COUNT(*) não olha para os valores dentro das colunas. Para ele, só importa se a linha existe ou não.

Por isso, COUNT(*) serve quando a pergunta é assim:

Quantos pedidos existem no total?
Quantos produtos existem no total?
Quantos usuários existem no total?
Quantas linhas restaram depois do filtro?

Por exemplo, quantos pedidos foram pagos:

SELECT COUNT(*) AS paid_orders_count
FROM orders
WHERE status = 'paid';

Aqui, primeiro o WHERE mantém apenas os pedidos pagos, e depois COUNT(*) conta as linhas restantes.

SUM, AVG, MIN e MAX

SUM calcula a soma.

SELECT SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid';

Assim você obtém a receita dos pedidos pagos.

AVG calcula o valor médio.

SELECT AVG(total_amount) AS avg_order_amount
FROM orders
WHERE status = 'paid';

Assim você obtém o ticket médio dos pedidos pagos.

MIN busca o valor mínimo.

SELECT MIN(total_amount) AS min_order_amount
FROM orders
WHERE status = 'paid';

MAX busca o valor máximo.

SELECT MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'paid';

Todas essas funções podem ser usadas juntas:

SELECT
    COUNT(*) AS paid_orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'paid';

Assim, uma única consulta retorna um resumo completo dos pedidos pagos.

O WHERE seleciona as linhas primeiro, o agregado calcula depois

É importante entender a ordem lógica aqui.

Nesta consulta:

SELECT
    COUNT(*) AS paid_orders_count,
    SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid';

o filtro age primeiro:

WHERE status = 'paid'

Ele mantém apenas os pedidos pagos.

E só depois os agregados calculam o total sobre as linhas restantes:

COUNT(*)
SUM(total_amount)

Ou seja, a consulta não soma todos os pedidos para depois marcar os pagos de algum jeito. Ela primeiro remove as linhas que não interessam, e só então calcula o total.

Isso é útil para qualquer refinamento.

Receita apenas dos pedidos pagos:

WHERE status = 'paid'

Quantidade de pedidos cancelados:

WHERE status = 'cancelled'

Ticket médio em um período específico:

WHERE created_at >= '2024-01-01'
  AND created_at <  '2024-02-01'

Primeiro se forma o conjunto de linhas. Depois o agregado o transforma em um número.

QUERY: Antes de ouvir o pulso, escolha de quem é o coração que você está ouvindo. Todos os pedidos, os pedidos pagos e os pedidos cancelados soam diferente.

Ouça o coração da loja: uma única linha com todos os sinais vitais — quantos pedidos pagos houve, quantos compradores diferentes fizeram esses pedidos, a receita e o menor e o maior pedido. ROUND(...,2) arredonda a média para duas casas decimais.

COUNT(*) e COUNT(DISTINCT col) respondem a perguntas diferentes. COUNT(*) conta linhas, enquanto COUNT(DISTINCT user_id) conta quantos compradores distintos aparecem nelas. Se há mais pedidos do que compradores, o arquivo não está com defeito: alguém voltou e comprou de novo.

COUNT(*), COUNT(col), COUNT(DISTINCT col)

COUNT tem várias formas, e cada uma responde a uma pergunta diferente.

Vamos ver em um exemplo pequeno.

Suponha que exista uma tabela visits:

idpromo_code
1SALE
2SALE
3
4VIP
5NULL

A consulta:

SELECT
    COUNT(*) AS rows_count,
    COUNT(promo_code) AS with_promo_code,
    COUNT(DISTINCT promo_code) AS different_promo_codes
FROM visits;

retorna:

rows_countwith_promo_codedifferent_promo_codes
532

Por quê?

COUNT(*) conta todas as linhas.

A tabela tem 5 linhas, então o resultado é 5.

COUNT(promo_code) conta as linhas em que promo_code está preenchido.

Ele não conta os valores NULL. Há códigos promocionais preenchidos em três linhas:

SALE
SALE
VIP

Então o resultado é 3.

COUNT(DISTINCT promo_code) conta os valores preenchidos distintos.

Dentre os valores preenchidos:

SALE
SALE
VIP

apenas dois são diferentes:

SALE
VIP

Então o resultado é 2.

Resumindo:

COUNT(*)                    → quantas linhas
COUNT(promo_code)           → em quantas linhas o código promocional está preenchido
COUNT(DISTINCT promo_code)  → quantos códigos promocionais diferentes e preenchidos existem

São três perguntas diferentes sobre os dados, por isso as respostas podem ser diferentes.

Agregados e NULL

A regra geral é:

os agregados costumam ignorar NULL.

Por exemplo, suponha os valores:

total_amount
1000
2000

SUM(total_amount) soma apenas os valores preenchidos:

1000 + 2000 = 3000

AVG(total_amount) também usa apenas os valores preenchidos:

(1000 + 2000) / 2 = 1500

E não assim:

(1000 + 2000 + NULL) / 3

MIN e MAX também buscam o mínimo e o máximo apenas entre os valores preenchidos.

A única exceção importante é COUNT(*).

COUNT(*) conta linhas, não o valor de uma coluna específica, por isso o NULL dentro das colunas não interfere nele.

Compare:

SELECT
    COUNT(*) AS rows_count,
    COUNT(total_amount) AS filled_amounts,
    SUM(total_amount) AS total_sum,
    AVG(total_amount) AS avg_amount
FROM orders;

COUNT(*) responde à pergunta:

quantas linhas

COUNT(total_amount) responde à pergunta:

em quantas linhas total_amount está preenchido

Essas não são a mesma coisa.

Quando não sobra nenhuma linha depois do WHERE

Às vezes o filtro não encontra nenhuma linha.

Por exemplo:

SELECT
    COUNT(*) AS orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'status_that_does_not_exist';

Se não existem esses pedidos, o resultado ainda será uma única linha, porque um agregado sem GROUP BY retorna um resumo para o conjunto selecionado.

Mas os valores serão diferentes:

orders_countrevenueavg_order_amountmin_order_amountmax_order_amount
0NULLNULLNULL

COUNT(*) retorna 0, porque não há linhas.

SUM, AVG, MIN e MAX retornam NULL, porque não há nada para somar, calcular a média ou comparar.

Se o relatório precisar de receita zero em vez de NULL, você pode usar COALESCE:

SELECT
    COUNT(*) AS orders_count,
    COALESCE(SUM(total_amount), 0) AS revenue
FROM orders
WHERE status = 'status_that_does_not_exist';

Isso não muda os dados na tabela. Apenas faz o resultado da consulta mostrar 0 em vez de um total vazio.

COUNT(*) e COUNT(1)

Às vezes você encontra isto em consultas escritas por outras pessoas:

COUNT(1)

Por exemplo:

SELECT COUNT(1)
FROM orders;

No PostgreSQL, para uma contagem comum de linhas, isso equivale a:

SELECT COUNT(*)
FROM orders;

Por quê?

Porque 1 é uma expressão que nunca é NULL. Ela existe em cada linha, então COUNT(1) conta cada linha.

Mas, para melhor legibilidade, é preferível escrever:

COUNT(*)

Assim fica claro de cara que você está contando linhas.

O mito de que COUNT(1) é necessariamente mais rápido que COUNT(*) não é uma regra útil no PostgreSQL. Por enquanto, lembre-se do jeito mais simples:

precisa contar linhas — escreva COUNT(*).

Por que uma coluna comum ao lado de um agregado dá erro

Olhe para esta consulta:

SELECT status, COUNT(*)
FROM orders;

Parece que ela deveria mostrar o status e a quantidade de pedidos.

Mas, sem GROUP BY, essa consulta falha com um erro.

Por quê?

COUNT(*) sem GROUP BY condensa todas as linhas selecionadas em uma única linha final.

Por exemplo:

count
128

status é uma coluna comum. Na tabela, pedidos diferentes têm status diferentes:

idstatus
1paid
2pending
3paid
4cancelled

Se todo o conjunto de linhas colapsou em uma única linha de resultado, qual status o SQL deveria mostrar ao lado da contagem geral?

paid?
pending?
cancelled?

O SQL não adivinha. Ele exige que você descreva a lógica explicitamente.

Se você precisa da contagem geral de todos os pedidos, remova status:

SELECT COUNT(*) AS orders_count
FROM orders;

Se você precisa da quantidade de pedidos por status, adicione GROUP BY:

SELECT status, COUNT(*) AS orders_count
FROM orders
GROUP BY status;

Aí o resultado não será uma única linha geral, mas uma linha separada para cada status:

statusorders_count
paid80
pending25
cancelled18
refunded5

Vamos detalhar o GROUP BY mais adiante. Por enquanto, o importante é entender o motivo do erro:

o agregado condensa as linhas, mas a coluna comum continua tendo vários valores possíveis. O SQL não escolhe um valor por você.

A regra principal dos agregados sem GROUP BY

Se a consulta não tem GROUP BY, as funções de agregação calculam o total sobre todo o conjunto de linhas que restou depois do WHERE.

SELECT COUNT(*), SUM(total_amount)
FROM orders
WHERE status = 'paid';

Uma consulta assim retorna uma única linha.

Você não pode colocar uma coluna comum ao lado de um agregado sem agrupamento:

SELECT status, COUNT(*)
FROM orders;

Essa consulta é inválida, porque status pode ser diferente em cada linha.

Interview question

Pergunta de entrevista:
Uma tabela tem cem linhas, e na coluna manager_id parte dos valores é NULL. O que retornam COUNT(*), COUNT(manager_id) e COUNT(DISTINCT manager_id), e por que os números são diferentes?

Resposta forte:
COUNT(*) retorna 100, porque conta linhas e não olha para o NULL dentro das colunas. COUNT(manager_id) retorna um valor menor ou igual a 100: conta apenas as linhas em que manager_id está preenchido, porque COUNT(col) ignora NULL. COUNT(DISTINCT manager_id) retorna a quantidade de valores diferentes e não vazios de manager_id, por isso esse número é menor ou igual a COUNT(manager_id). São três perguntas diferentes: quantas linhas, em quantas linhas o gerente está preenchido, quantos gerentes diferentes aparecem.


Pergunta de entrevista:
Por que SELECT status, COUNT(*) FROM orders sem GROUP BY é um erro?

Resposta forte:
COUNT(*) sem GROUP BY condensa todo o conjunto de linhas em uma única linha final, e status é uma coluna comum, que pode ter muitos valores diferentes em linhas diferentes. O SQL não adivinha qual status mostrar ao lado da contagem geral. É preciso ou remover status, se você quer o total geral, ou adicionar GROUP BY status, se você quer a contagem para cada status.


Pergunta de entrevista:
Em que SUM(total_amount) se diferencia de COUNT(*) num conjunto de linhas vazio?

Resposta forte:
Se não sobrou nenhuma linha depois do WHERE, COUNT(*) retorna 0, porque de fato não há linhas. Já SUM(total_amount) retorna NULL, porque não há nada para somar. Se o relatório precisar de uma soma zero, você pode envolver o agregado em COALESCE(SUM(total_amount), 0).

Check yourself
O que a consulta SELECT AVG(price) FROM products; retorna quando o GROUP BY não é especificado?
Check yourself
O que COUNT(*) conta?
Check yourself
Em que COUNT(user_id) se diferencia de COUNT(DISTINCT user_id)?
Check yourself
Por que a consulta SELECT status, COUNT(*) FROM orders; sem GROUP BY é inválida?

QUERY: Linhas brutas mostram eventos. Agregados mostram o pulso: quantos, em que valor total, dentro de quais limites e em qual ritmo médio.

Practice: solve the tasks
Solved 0 of 3 · any 2 is enough to pass