SQL PARTITION BY
Nas últimas três aulas, a janela era sempre o resultado inteiro: a média de todos os produtos, o ranking de todos, o pedido anterior da loja. Para comparar um produto só com os da categoria dele, ou um pedido só com os do mesmo cliente, a Ana precisou de um WHERE que deixava um grupo de cada vez. Com PARTITION BY ("dividir por"), a janela se divide em grupos sozinha e recomeça em cada um: a média de cada categoria, o ranking dentro de cada categoria, o intervalo entre os pedidos de cada cliente, tudo numa consulta só.
- O que é uma partição e como
PARTITION BYdivide a janela. - A diferença entre
GROUP BY(as linhas viram uma por grupo) ePARTITION BY(as linhas ficam). PARTITION BYcomORDER BY: ranking e acumulado dentro de cada grupo.- O "melhor de cada grupo": o campeão de cada categoria, o último pedido de cada cliente.
LAGpor cliente, e o acumulado que recomeça a cada ano.- Os erros mais comuns, com a mensagem real do SQLite.
Esta aula fecha a série das funções de janela: Funções de Janela (OVER), ROW_NUMBER, RANK e DENSE_RANK e LAG e LEAD. Todos os exemplos rodam de verdade no seu navegador, no banco da Loja Aurora.
A ideia: as turmas da escola
Uma escola tem três turmas do 6º ano. No boletim de cada aluno, a coluna "média" é a média da turma dele, e não da escola inteira. E o "1º lugar" é o 1º da turma: cada turma tem o seu. A secretaria não junta os alunos nem os separa em planilhas diferentes; ela só faz cada conta dentro da turma.
| Na escola | No SQL |
|---|---|
| A turma de cada aluno | a partição: o grupo de linhas com o mesmo valor na coluna do PARTITION BY |
| "Média da turma" no boletim | AVG(nota) OVER (PARTITION BY turma) |
| "1º lugar da turma" | RANK() OVER (PARTITION BY turma ORDER BY nota DESC) |
| O cartaz com a média de cada turma, uma linha por turma | GROUP BY turma (os alunos somem) |
1. A média da categoria ao lado de cada produto
Os produtos de Livros e de Papelaria, cada um com a média de preço da sua categoria:
SELECT c.nome AS categoria,
p.nome,
p.preco,
ROUND(AVG(p.preco) OVER (PARTITION BY p.categoria_id), 2) AS media_categoria
FROM produtos AS p
JOIN categorias AS c ON c.id = p.categoria_id
WHERE p.categoria_id IN (1, 4)
ORDER BY c.nome, p.preco DESC;
- AVG(p.preco) OVER (…)
- Uma média como função de janela, como na aula Funções de Janela.
- PARTITION BY p.categoria_id
- "Divida por categoria". Em vez de uma janela com todas as linhas, cada linha enxerga só as linhas da mesma categoria que ela. Cada categoria é uma partição.
- WHERE p.categoria_id IN (1, 4)
- Só para o quadro ficar curto: duas categorias, duas partições. Tire o
WHEREe as 8 categorias ganham, cada uma, a sua média.
Os 5 livros mostram 42.06, a média dos livros; os 6 itens de papelaria mostram 21.78, a média da papelaria. Nenhuma linha sumiu.
GROUP BY × PARTITION BY
Compare com o GROUP BY da aula GROUP BY, que calcula as mesmas médias:
SELECT c.nome AS categoria,
ROUND(AVG(p.preco), 2) AS media_categoria
FROM produtos AS p
JOIN categorias AS c ON c.id = p.categoria_id
WHERE p.categoria_id IN (1, 4)
GROUP BY p.categoria_id
ORDER BY c.nome;
GROUP BY categoria_id | OVER (PARTITION BY categoria_id) | |
|---|---|---|
| Linhas no resultado | Uma por categoria (2) | Todas (11) |
Pode mostrar nome do produto? | Não faz sentido: o grupo tem vários nomes | Sim: cada linha continua sendo um produto |
| Onde se escreve | Uma cláusula do SELECT, depois do WHERE | Dentro do OVER (…) de cada função |
| Para quê | O resumo de cada grupo | Cada linha comparada com o seu grupo |
Comparar cada um com o seu grupo
Com a média da categoria na linha, dá para medir "caro para a categoria dele". A Mochila Escolar Azul custa R$ 89,99: barata para um eletrônico, caríssima para a papelaria.
SELECT categoria_id,
nome,
preco,
ROUND(preco - AVG(preco) OVER (PARTITION BY categoria_id), 2) AS acima_da_media,
ROUND(100.0 * preco / SUM(preco) OVER (PARTITION BY categoria_id), 1) AS pct_da_categoria
FROM produtos
WHERE categoria_id = 1
ORDER BY preco DESC;
2. PARTITION BY + ORDER BY: o ranking dentro de cada grupo
Dentro do OVER, o PARTITION BY vem primeiro e o ORDER BY depois. A numeração (ou o acumulado) anda na ordem pedida e volta a 1 em cada partição:
SELECT categoria_id,
nome,
preco,
ROW_NUMBER() OVER (PARTITION BY categoria_id ORDER BY preco DESC) AS posicao
FROM produtos
WHERE categoria_id IN (1, 4, 8)
ORDER BY categoria_id, posicao;
- PARTITION BY categoria_id
- Uma numeração separada para cada categoria.
- ORDER BY preco DESC
- Dentro de cada categoria, do mais caro para o mais barato.
- ROW_NUMBER()
- 1, 2, 3… e de novo 1 quando a categoria muda (aula ROW_NUMBER, RANK e DENSE_RANK).
- ORDER BY categoria_id, posicao (no fim)
- Só a arrumação do resultado: categoria por categoria, em ordem de posição.
A papelaria vai de 1 a 6, os livros recomeçam em 1 e vão até 5, a jardinagem também de 1 a 5. A Mochila, o Atlas Geográfico e a Tesoura de Poda são os mais caros das suas categorias.
3. O melhor de cada grupo
Este é o uso mais famoso do PARTITION BY: ficar com uma linha por grupo, mas a linha inteira (e não só um MAX). Numere dentro de cada grupo numa CTE e fique com o número 1.
O último pedido de cada cliente
WITH numerados AS (
SELECT cliente_id, id, data,
ROW_NUMBER() OVER (PARTITION BY cliente_id ORDER BY data DESC) AS n
FROM pedidos
)
SELECT c.nome, u.id AS pedido, u.data
FROM numerados AS u
JOIN clientes AS c ON c.id = u.cliente_id
WHERE u.n = 1
ORDER BY u.data
LIMIT 5;
- PARTITION BY cliente_id ORDER BY data DESC
- Para cada cliente, os pedidos do mais recente para o mais antigo. O mais recente ganha 1.
- WHERE u.n = 1
- Fica só o mais recente de cada cliente. O filtro está fora da CTE porque uma janela não pode ir no
WHERE. - ORDER BY u.data LIMIT 5
- Os 5 clientes que estão há mais tempo sem comprar: o último pedido do Tiago Fontes foi em 8 de janeiro de 2026.
Sem o LIMIT, saem 26 linhas: um último pedido para cada um dos 26 clientes que já compraram.
MAX(data) com GROUP BY?
SELECT cliente_id, MAX(data) FROM pedidos GROUP BY cliente_id dá a data do último pedido, mas não o pedido: o id, o frete, o vendedor. Para pegar a linha inteira, você teria de juntar o resultado de volta com pedidos. Com ROW_NUMBER e PARTITION BY, a linha inteira já vem pronta.
Missão: a numeração abaixo não recomeça por cliente, e por isso só sobrou um pedido: o mais recente da loja. Acrescente o PARTITION BY para que cada cliente tenha a sua numeração, e o resultado mostre o último pedido de cada um dos 26 clientes.
WITH numerados AS (
SELECT cliente_id, id, data,
ROW_NUMBER() OVER (ORDER BY data DESC) AS n
FROM pedidos
)
SELECT c.nome, u.id AS pedido, u.data
FROM numerados AS u
JOIN clientes AS c ON c.id = u.cliente_id
WHERE u.n = 1
ORDER BY c.nome;
WITH numerados AS (
SELECT cliente_id, id, data,
ROW_NUMBER() OVER (PARTITION BY cliente_id ORDER BY data DESC) AS n
FROM pedidos
)
SELECT c.nome, u.id AS pedido, u.data
FROM numerados AS u
JOIN clientes AS c ON c.id = u.cliente_id
WHERE u.n = 1
ORDER BY c.nome;
4. LAG por cliente: o intervalo entre pedidos de todos de uma vez
Na aula LAG e LEAD, a Ana calculou os dias entre os pedidos de um cliente, com WHERE cliente_id = 1. Com PARTITION BY cliente_id, o "pedido anterior" passa a ser o anterior do mesmo cliente, para todos ao mesmo tempo:
SELECT cliente_id,
id,
data,
LAG(data) OVER (PARTITION BY cliente_id ORDER BY data) AS anterior,
CAST(julianday(data)
- julianday(LAG(data) OVER (PARTITION BY cliente_id ORDER BY data)) AS INTEGER) AS dias
FROM pedidos
WHERE cliente_id IN (1, 2, 13)
ORDER BY cliente_id, data;
Cada cliente começa com NULL (o primeiro pedido dele não tem anterior), e a conta nunca mistura um cliente com outro. O WHERE deixou só três clientes para o quadro ficar curto; a mesma janela, sem o WHERE, calcula os intervalos dos 26. Resumindo numa CTE:
WITH intervalos AS (
SELECT cliente_id,
julianday(data)
- julianday(LAG(data) OVER (PARTITION BY cliente_id ORDER BY data)) AS dias
FROM pedidos
)
SELECT COUNT(dias) AS intervalos,
ROUND(AVG(dias), 1) AS media_de_dias,
MIN(dias) AS menor,
MAX(dias) AS maior
FROM intervalos;
64 intervalos (90 pedidos menos o primeiro de cada um dos 26 clientes), com média de 302,1 dias: os clientes da Aurora voltam, em média, uma vez a cada dez meses.
Experimente: troque a janela para LEAD e calcule os dias até o próximo pedido de cada cliente. Depois tente PARTITION BY funcionario_id no lugar de cliente_id: o intervalo entre os pedidos atendidos por cada vendedor.
SELECT cliente_id,
id,
data,
CAST(julianday(data)
- julianday(LAG(data) OVER (PARTITION BY cliente_id ORDER BY data)) AS INTEGER) AS dias
FROM pedidos
ORDER BY cliente_id, data;
5. Particionar por uma expressão: o acumulado do ano
A partição não precisa ser uma coluna pronta: pode ser uma conta. A Ana quer o total vendido acumulado dentro de cada ano, recomeçando em janeiro. O ano são os 4 primeiros caracteres do mês (substr(mes, 1, 4), aula Funções de Texto):
WITH mensal AS (
SELECT strftime('%Y-%m', p.data) AS mes,
ROUND(SUM(i.quantidade * i.preco_unitario), 2) AS vendas
FROM pedidos AS p
JOIN itens_pedido AS i ON i.pedido_id = p.id
GROUP BY mes
),
acumulado AS (
SELECT mes,
vendas,
ROUND(SUM(vendas) OVER (PARTITION BY substr(mes, 1, 4) ORDER BY mes), 2) AS acumulado_do_ano
FROM mensal
)
SELECT mes, vendas, acumulado_do_ano
FROM acumulado
WHERE mes LIKE '%-12' OR mes = '2026-10';
- PARTITION BY substr(mes, 1, 4)
- Uma partição por ano:
'2024','2025','2026'. - ORDER BY mes
- Dentro de cada ano, o acumulado vai de janeiro em diante e recomeça no ano seguinte. Os meses não se repetem, então a moldura padrão não traz surpresa de empate (aula Funções de Janela, seção 4).
- WHERE mes LIKE '%-12' OR …
- Mostra só o fechamento de cada ano (dezembro, e outubro em 2026, o último mês com pedidos). O filtro está fora da CTE de propósito; você vai ver por quê em Quando dá errado.
2024 fechou com R$ 8.280,80 em produtos, 2025 com R$ 5.464,52 (o mesmo número do acumulado da aula de janelas), e 2026 está em R$ 4.792,08 até outubro.
Mais de uma coluna
PARTITION BY aceita várias colunas separadas por vírgula: a partição é cada combinação. Os eletrônicos e brinquedos, contando quantos produtos há em cada par (categoria, descontinuado):
SELECT categoria_id,
descontinuado,
nome,
COUNT(*) OVER (PARTITION BY categoria_id, descontinuado) AS no_grupo
FROM produtos
WHERE categoria_id IN (2, 5)
ORDER BY categoria_id, descontinuado, nome;
Os eletrônicos se dividem em 6 ativos e 1 descontinuado (o Pen Drive 32GB); os brinquedos, em 4 ativos e 1 descontinuado (o Carrinho de Fricção).
6. Formas diferentes de fazer a mesma coisa
A média da categoria ao lado de cada produto (seção 1) também sai de dois jeitos sem janela:
a) GROUP BY numa CTE e JOIN de volta
WITH medias AS ( SELECT categoria_id, ROUND(AVG(preco), 2) AS media_categoria FROM produtos GROUP BY categoria_id ) SELECT p.categoria_id, p.nome, p.preco, m.media_categoria FROM produtos AS p JOIN medias AS m ON m.categoria_id = p.categoria_id WHERE p.categoria_id = 4 ORDER BY p.preco DESC;
b) Subconsulta correlacionada
SELECT p.categoria_id,
p.nome,
p.preco,
(SELECT ROUND(AVG(p2.preco), 2)
FROM produtos AS p2
WHERE p2.categoria_id = p.categoria_id) AS media_categoria
FROM produtos AS p
WHERE p.categoria_id = 4
ORDER BY p.preco DESC;
| Forma | Vantagem | Desvantagem |
|---|---|---|
AVG(…) OVER (PARTITION BY …) | Curta; a conta fica ao lado da coluna que ela explica; ranking, acumulado e LAG por grupo no mesmo jeito | Precisa de SQLite 3.25 ou mais novo |
GROUP BY numa CTE + JOIN | Funciona em qualquer banco; a CTE de médias pode servir para outras consultas | Dois passos; para ranking e "anterior" não serve |
| Subconsulta correlacionada | Funciona em qualquer banco | Roda uma vez por linha; a condição de grupo se repete |
Quando dá errado
SELECT cliente_id,
id,
data,
CAST(julianday(data)
- julianday(LAG(data) OVER (ORDER BY cliente_id, data)) AS INTEGER) AS dias
FROM pedidos
WHERE cliente_id IN (1, 2)
ORDER BY cliente_id, data;
O que acontece: nenhum erro, mas o primeiro pedido do cliente 2 aparece com -897 dias. O ORDER BY cliente_id, data coloca os pedidos em ordem de cliente, mas a janela continua sendo uma só: o "anterior" do primeiro pedido do cliente 2 é o último pedido do cliente 1, de 2026.
Como consertar: quem separa os grupos é o PARTITION BY, não o ORDER BY: LAG(data) OVER (PARTITION BY cliente_id ORDER BY data). Aí o primeiro pedido de cada cliente fica com NULL, como deve ser.
A Ana quis ver só o fim de 2024 e o começo de 2025, e pôs o filtro na mesma consulta da janela:
WITH mensal AS (
SELECT strftime('%Y-%m', p.data) AS mes,
ROUND(SUM(i.quantidade * i.preco_unitario), 2) AS vendas
FROM pedidos AS p
JOIN itens_pedido AS i ON i.pedido_id = p.id
GROUP BY mes
)
SELECT mes,
vendas,
ROUND(SUM(vendas) OVER (PARTITION BY substr(mes, 1, 4) ORDER BY mes), 2) AS acumulado_do_ano
FROM mensal
WHERE mes BETWEEN '2024-11' AND '2025-02'
ORDER BY mes;
O que acontece: o acumulado de 2024 começa em novembro (128.53) e fecha dezembro em 1344.35, quando o ano todo teve 8280.8. O WHERE roda antes da janela: os meses de janeiro a outubro de 2024 foram embora antes de serem somados.
Como consertar: calcule a janela numa CTE, com todas as linhas, e filtre por fora, como na seção 5.
SELECT nome,
preco,
ROW_NUMBER() OVER (PARTITION BY id ORDER BY preco DESC) AS posicao
FROM produtos
LIMIT 4;
O que acontece: todas as posições saem 1. Cada id é único, então cada produto ficou sozinho na sua partição, e numa partição de uma linha só todo mundo é o primeiro.
Como consertar: particione pela coluna que forma os grupos (categoria_id), nunca pela que identifica cada linha.
SELECT categoria_id AS cat,
nome,
ROW_NUMBER() OVER (PARTITION BY cat ORDER BY preco DESC) AS posicao
FROM produtos;
A mensagem: no such column: cat, "coluna inexistente: cat". O apelido cat é criado na lista do SELECT, e a janela não o enxerga.
Como consertar: use a coluna (ou a expressão) original: PARTITION BY categoria_id.
SELECT nome,
ROW_NUMBER() OVER (ORDER BY preco DESC PARTITION BY categoria_id) AS posicao
FROM produtos;
A mensagem: near "PARTITION": syntax error. Dentro do OVER a ordem é fixa: primeiro PARTITION BY, depois ORDER BY, depois a moldura (ROWS …).
Como consertar: OVER (PARTITION BY categoria_id ORDER BY preco DESC).
SELECT nome, AVG(preco) OVER (PARTITION categoria_id) AS media FROM produtos;
A mensagem: near "categoria_id": syntax error. São duas palavras: PARTITION BY, como em GROUP BY e ORDER BY.
PARTITION BYé do padrão SQL e se escreve igual no PostgreSQL, no SQL Server e no MySQL (a partir da 8.0), com a mesma ordem dentro doOVER.- SQL Server: a documentação diz que o
PARTITION BYsó pode usar colunas que vêm doFROM, e não apelidos nem expressões da lista doSELECT, a mesma regra do errono such column: catacima.
Referência rápida
| Escrita | O que faz |
|---|---|
AVG(x) OVER (PARTITION BY g) | A média do grupo g, ao lado de cada linha |
ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) | Numeração que recomeça em cada grupo |
RANK() OVER (PARTITION BY g ORDER BY x DESC) | Ranking dentro de cada grupo |
SUM(x) OVER (PARTITION BY g ORDER BY d) | Acumulado que recomeça em cada grupo |
LAG(x) OVER (PARTITION BY g ORDER BY d) | O valor anterior do mesmo grupo |
PARTITION BY a, b | Uma partição por combinação de a e b |
PARTITION BY substr(mes, 1, 4) | Partição por uma expressão (o ano) |
WITH n AS (… ROW_NUMBER() OVER (PARTITION BY g ORDER BY …) AS n …) SELECT … WHERE n = 1 | Uma linha (a primeira) de cada grupo |
Ordem dentro do OVER | PARTITION BY → ORDER BY → moldura |
| Mensagem | Quer dizer |
|---|---|
no such column: cat | Apelido do SELECT usado no PARTITION BY |
near "PARTITION": syntax error | ORDER BY antes do PARTITION BY |
near "categoria_id": syntax error | Faltou o BY |
| (dias negativos no primeiro pedido de um cliente) | LAG sem PARTITION BY: cruzou de um cliente para outro |
| (acumulado menor que o esperado) | O WHERE na mesma consulta cortou linhas antes da janela |
Exercícios
1. Dividir por
Complete para mostrar cada funcionário com a média de salário do seu cargo:
2. A ordem dentro do OVER
Complete o ranking de preço dentro de cada categoria:
3. Qual é a diferença entre GROUP BY categoria_id e OVER (PARTITION BY categoria_id)?
4. Um LAG(data) OVER (ORDER BY cliente_id, data) deu dias negativos no primeiro pedido de cada cliente. Por quê?
5. Para mostrar o pedido mais recente de cada cliente, com todas as colunas do pedido, a melhor forma é…
Missão final: o campeão de cada categoria
Missão: a Ana quer o produto que mais vendeu (em valor) em cada categoria. O ranking abaixo é de todos os produtos juntos, e por isso só sobrou o campeão geral. Faça o ranking recomeçar em cada categoria para aparecerem os 8 campeões.
WITH vendas AS (
SELECT pr.categoria_id, pr.nome,
ROUND(SUM(i.quantidade * i.preco_unitario), 2) AS vendido
FROM itens_pedido AS i
JOIN produtos AS pr ON pr.id = i.produto_id
GROUP BY pr.id
),
ranking AS (
SELECT categoria_id, nome, vendido,
RANK() OVER (ORDER BY vendido DESC) AS posicao
FROM vendas
)
SELECT c.nome AS categoria, r.nome AS produto, r.vendido
FROM ranking AS r
JOIN categorias AS c ON c.id = r.categoria_id
WHERE r.posicao = 1
ORDER BY r.vendido DESC;
WITH vendas AS (
SELECT pr.categoria_id, pr.nome,
ROUND(SUM(i.quantidade * i.preco_unitario), 2) AS vendido
FROM itens_pedido AS i
JOIN produtos AS pr ON pr.id = i.produto_id
GROUP BY pr.id
),
ranking AS (
SELECT categoria_id, nome, vendido,
RANK() OVER (PARTITION BY categoria_id ORDER BY vendido DESC) AS posicao
FROM vendas
)
SELECT c.nome AS categoria, r.nome AS produto, r.vendido
FROM ranking AS r
JOIN categorias AS c ON c.id = r.categoria_id
WHERE r.posicao = 1
ORDER BY r.vendido DESC;
Do Jogo de Panelas (Casa e Cozinha, R$ 1.999,20) ao Regador Plástico 5L (Jardim, R$ 158,70): cada categoria tem o seu campeão. Troque vendido por unidades (SUM(i.quantidade)) e o RANK mostra empates: em Alimentos, Brinquedos e Livros, dois ou mais produtos dividem o 1º lugar.
Resumo
PARTITION BYdivide a janela em grupos (partições), e cada função de janela é calculada dentro do grupo de cada linha. Nenhuma linha some.GROUP BYresume (uma linha por grupo);PARTITION BYcompara (cada linha com o seu grupo).- Dentro do
OVER:PARTITION BY, depoisORDER BY, depois a moldura. ComORDER BY, a numeração, o ranking e o acumulado recomeçam em cada partição. - "O melhor de cada grupo":
ROW_NUMBER()ouRANK()comPARTITION BYnuma CTE, eWHERE posicao = 1fora. LAGeLEADcomPARTITION BYnunca cruzam de um grupo para outro; sem ele, o primeiro pedido de um cliente "herda" a data de outro.- A partição pode ser uma expressão (
substr(mes, 1, 4)) ou várias colunas. OWHEREda mesma consulta corta linhas antes da janela; para filtrar o resultado, use uma CTE. - Próxima aula: guardar e consultar dados em JSON no SQL.