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ó.

Você vai aprender
  • O que é uma partição e como PARTITION BY divide a janela.
  • A diferença entre GROUP BY (as linhas viram uma por grupo) e PARTITION BY (as linhas ficam).
  • PARTITION BY com ORDER 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.
  • LAG por 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 escolaNo SQL
A turma de cada alunoa partição: o grupo de linhas com o mesmo valor na coluna do PARTITION BY
"Média da turma" no boletimAVG(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 turmaGROUP 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 WHERE e 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_idOVER (PARTITION BY categoria_id)
Linhas no resultadoUma por categoria (2)Todas (11)
Pode mostrar nome do produto?Não faz sentido: o grupo tem vários nomesSim: cada linha continua sendo um produto
Onde se escreveUma cláusula do SELECT, depois do WHEREDentro do OVER (…) de cada função
Para quêO resumo de cada grupoCada 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.

Por que não 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;
FormaVantagemDesvantagem
AVG(…) OVER (PARTITION BY …)Curta; a conta fica ao lado da coluna que ela explica; ranking, acumulado e LAG por grupo no mesmo jeitoPrecisa de SQLite 3.25 ou mais novo
GROUP BY numa CTE + JOINFunciona em qualquer banco; a CTE de médias pode servir para outras consultasDois passos; para ranking e "anterior" não serve
Subconsulta correlacionadaFunciona em qualquer bancoRoda uma vez por linha; a condição de grupo se repete

Quando dá errado

Esqueceu o PARTITION BY no LAG: um cliente "herda" a data de outro
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.

O WHERE na mesma consulta corta o acumulado

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.

Partição pela coluna errada: todo mundo em 1º
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.

Apelido no PARTITION BY
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.

ORDER BY antes do PARTITION BY
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).

Esqueceu o BY
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.

Nos outros bancos
  • 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 do OVER.
  • SQL Server: a documentação diz que o PARTITION BY só pode usar colunas que vêm do FROM, e não apelidos nem expressões da lista do SELECT, a mesma regra do erro no such column: cat acima.

Referência rápida

EscritaO 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, bUma 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 = 1Uma linha (a primeira) de cada grupo
Ordem dentro do OVERPARTITION BY → ORDER BY → moldura
MensagemQuer dizer
no such column: catApelido do SELECT usado no PARTITION BY
near "PARTITION": syntax errorORDER BY antes do PARTITION BY
near "categoria_id": syntax errorFaltou 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:

SELECT nome, cargo, salario, AVG(salario) OVER ( cargo) AS media_cargo FROM funcionarios;

2. A ordem dentro do OVER

Complete o ranking de preço dentro de cada categoria:

RANK() OVER (PARTITION BY categoria_id preco DESC)

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 BY divide a janela em grupos (partições), e cada função de janela é calculada dentro do grupo de cada linha. Nenhuma linha some.
  • GROUP BY resume (uma linha por grupo); PARTITION BY compara (cada linha com o seu grupo).
  • Dentro do OVER: PARTITION BY, depois ORDER BY, depois a moldura. Com ORDER BY, a numeração, o ranking e o acumulado recomeçam em cada partição.
  • "O melhor de cada grupo": ROW_NUMBER() ou RANK() com PARTITION BY numa CTE, e WHERE posicao = 1 fora.
  • LAG e LEAD com PARTITION BY nunca 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. O WHERE da 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.