Você provavelmente já se deparou com as funções de janela: SUM() OVER (...), ROW_NUMBER() e companhia. Mas no momento em que você recorre a totais acumulados e médias móveis, surge um terceiro componente da janela que muita gente pula: o frame. O frame decide exatamente quais linhas dentro da partição participam do cálculo para a linha atual. Entendê-lo errado produz bugs silenciosos e nada óbvios; o mais famoso é um LAST_VALUE que teima em devolver o valor errado. Vamos trabalhar isso sobre um esquema de orders.
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
created_at date NOT NULL,
amount numeric(10,2) NOT NULL
);
A anatomia de uma janela
Uma definição de janela completa tem três partes: PARTITION BY (em quais grupos dividir), ORDER BY (como ordenar dentro de cada grupo) e o frame (ROWS/RANGE/GROUPS BETWEEN ...). O frame define um intervalo de linhas relativo à linha atual.
SELECT
id,
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
Os limites de frame principais:
UNBOUNDED PRECEDING — desde o início da partição;
N PRECEDING / N FOLLOWING — N linhas para trás/para frente;
CURRENT ROW — a linha atual;
UNBOUNDED FOLLOWING — até o final da partição.
Importante: se você escrever um ORDER BY mas omitir o frame, o motor fornece um por conta própria — e quase nunca é o que você espera. Mais sobre isso abaixo.
Total acumulado: ROWS
O caso clássico é uma soma cumulativa de pedidos por data. Um frame do início até a linha atual faz exatamente isso:
SELECT
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY created_at, id;
Repare no id dentro do ORDER BY: ele torna a ordenação determinística. Se duas linhas compartilham o mesmo created_at, a ordem entre elas fica indefinida sem um critério de desempate, e o resultado pode mudar entre execuções.
Quer um total acumulado por cliente? Adicione PARTITION BY:
SELECT
customer_id,
created_at,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total
FROM orders;
Média móvel: uma janela de largura fixa
Uma média móvel de 3 linhas (a linha atual mais as duas anteriores) é o frame 2 PRECEDING AND CURRENT ROW:
SELECT
created_at,
amount,
AVG(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders;
Quer uma média centralizada (uma linha de cada lado)? Use BETWEEN 1 PRECEDING AND 1 FOLLOWING.
Pegadinha: no início de uma partição a janela fica "semipreenchida" — a primeira linha vê apenas um registro no seu frame, a segunda vê dois. AVG lida bem com isso (divide pela contagem real de linhas), mas se você quiser uma média honesta "apenas de janelas completas", filtre por COUNT(*) OVER (...) e descarte os frames incompletos.
ROWS vs RANGE — são bichos diferentes
Esta é a grande. ROWS conta linhas físicas. RANGE trabalha sobre o valor da coluna do ORDER BY: toda linha que compartilha o valor da linha atual (seus pares) entra no frame.
SELECT
created_at,
amount,
SUM(amount) OVER (ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_rows,
SUM(amount) OVER (ORDER BY created_at
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_range
FROM orders;
Para as duas linhas de 2026-01-10, by_rows dá valores diferentes (as linhas são visitadas uma de cada vez), enquanto by_range dá o mesmo valor para ambas, porque são pares de data e se fundem em um único passo. Essa é uma fonte comum de passos duplicados "esquisitos" em um total acumulado.
O PostgreSQL também suporta intervalos baseados em valor: RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW é uma verdadeira janela "móvel de 7 dias corridos", mesmo em dias sem pedidos. Há também GROUPS, um frame contado em unidades de grupos de pares.
Diferenças entre motores: o MySQL suporta ROWS e RANGE (desde a 8.0), mas não GROUPS, nem RANGE com INTERVAL no mesmo formato. O ClickHouse tem funções de janela, mas frames RANGE baseados em intervalo são limitados — para janelas móveis temporais o pessoal costuma recorrer a ROWS ou a auxiliares de arrays como groupArray. Sempre confira a documentação da sua versão exata.
A armadilha do LAST_VALUE e o frame padrão
Aqui vem a parte mais traiçoeira. Quando há um ORDER BY mas nenhum frame é informado, o padrão do SQL standard é:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Então a janela só alcança até a linha atual, não até o final da partição. Para FIRST_VALUE isso é invisível (a primeira linha está sempre no frame), mas LAST_VALUE quebra:
SELECT
created_at,
amount,
LAST_VALUE(amount) OVER (ORDER BY created_at, id) AS wrong_last
FROM orders;
O "último valor" por padrão é a última linha do frame, e o frame termina na linha atual. Você obtém o pedido atual, não o último. A correção é um frame explícito até o final da partição:
SELECT
created_at,
amount,
LAST_VALUE(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS correct_last
FROM orders;
Uma alternativa que evita ficar mexendo nos frames: FIRST_VALUE com a ordenação invertida, ou MAX(...) OVER (PARTITION BY ...) sem ORDER BY (aí o frame cobre a partição inteira).
Lembre-se de três coisas: sempre adicione um critério de desempate ao ORDER BY; o frame padrão é RANGE ... CURRENT ROW, não "a partição inteira"; e para LAST_VALUE você quase sempre vai querer escrever explicitamente ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Você provavelmente já se deparou com as funções de janela:
SUM() OVER (...),ROW_NUMBER()e companhia. Mas no momento em que você recorre a totais acumulados e médias móveis, surge um terceiro componente da janela que muita gente pula: o frame. O frame decide exatamente quais linhas dentro da partição participam do cálculo para a linha atual. Entendê-lo errado produz bugs silenciosos e nada óbvios; o mais famoso é umLAST_VALUEque teima em devolver o valor errado. Vamos trabalhar isso sobre um esquema deorders.CREATE TABLE orders ( id bigint PRIMARY KEY, customer_id bigint NOT NULL, created_at date NOT NULL, amount numeric(10,2) NOT NULL );A anatomia de uma janela
Uma definição de janela completa tem três partes:
PARTITION BY(em quais grupos dividir),ORDER BY(como ordenar dentro de cada grupo) e o frame (ROWS/RANGE/GROUPS BETWEEN ...). O frame define um intervalo de linhas relativo à linha atual.SELECT id, created_at, amount, SUM(amount) OVER ( ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- the frame ) AS running_total FROM orders;Os limites de frame principais:
UNBOUNDED PRECEDING— desde o início da partição;N PRECEDING/N FOLLOWING— N linhas para trás/para frente;CURRENT ROW— a linha atual;UNBOUNDED FOLLOWING— até o final da partição.Importante: se você escrever um
ORDER BYmas omitir o frame, o motor fornece um por conta própria — e quase nunca é o que você espera. Mais sobre isso abaixo.Total acumulado: ROWS
O caso clássico é uma soma cumulativa de pedidos por data. Um frame do início até a linha atual faz exatamente isso:
SELECT created_at, amount, SUM(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM orders ORDER BY created_at, id;Repare no
iddentro doORDER BY: ele torna a ordenação determinística. Se duas linhas compartilham o mesmocreated_at, a ordem entre elas fica indefinida sem um critério de desempate, e o resultado pode mudar entre execuções.Quer um total acumulado por cliente? Adicione
PARTITION BY:SELECT customer_id, created_at, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS customer_running_total FROM orders;Média móvel: uma janela de largura fixa
Uma média móvel de 3 linhas (a linha atual mais as duas anteriores) é o frame
2 PRECEDING AND CURRENT ROW:SELECT created_at, amount, AVG(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg_3 FROM orders;Quer uma média centralizada (uma linha de cada lado)? Use
BETWEEN 1 PRECEDING AND 1 FOLLOWING.Pegadinha: no início de uma partição a janela fica "semipreenchida" — a primeira linha vê apenas um registro no seu frame, a segunda vê dois.
AVGlida bem com isso (divide pela contagem real de linhas), mas se você quiser uma média honesta "apenas de janelas completas", filtre porCOUNT(*) OVER (...)e descarte os frames incompletos.ROWS vs RANGE — são bichos diferentes
Esta é a grande.
ROWSconta linhas físicas.RANGEtrabalha sobre o valor da coluna doORDER BY: toda linha que compartilha o valor da linha atual (seus pares) entra no frame.-- Two orders on the same day: created_at = '2026-01-10' SELECT created_at, amount, SUM(amount) OVER (ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_rows, SUM(amount) OVER (ORDER BY created_at RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_range FROM orders;Para as duas linhas de
2026-01-10,by_rowsdá valores diferentes (as linhas são visitadas uma de cada vez), enquantoby_rangedá o mesmo valor para ambas, porque são pares de data e se fundem em um único passo. Essa é uma fonte comum de passos duplicados "esquisitos" em um total acumulado.O PostgreSQL também suporta intervalos baseados em valor:
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROWé uma verdadeira janela "móvel de 7 dias corridos", mesmo em dias sem pedidos. Há tambémGROUPS, um frame contado em unidades de grupos de pares.Diferenças entre motores: o MySQL suporta
ROWSeRANGE(desde a 8.0), mas nãoGROUPS, nemRANGEcomINTERVALno mesmo formato. O ClickHouse tem funções de janela, mas framesRANGEbaseados em intervalo são limitados — para janelas móveis temporais o pessoal costuma recorrer aROWSou a auxiliares de arrays comogroupArray. Sempre confira a documentação da sua versão exata.A armadilha do LAST_VALUE e o frame padrão
Aqui vem a parte mais traiçoeira. Quando há um
ORDER BYmas nenhum frame é informado, o padrão do SQL standard é:RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWEntão a janela só alcança até a linha atual, não até o final da partição. Para
FIRST_VALUEisso é invisível (a primeira linha está sempre no frame), masLAST_VALUEquebra:-- BUG: returns the amount of the CURRENT row, not the last one SELECT created_at, amount, LAST_VALUE(amount) OVER (ORDER BY created_at, id) AS wrong_last FROM orders;O "último valor" por padrão é a última linha do frame, e o frame termina na linha atual. Você obtém o pedido atual, não o último. A correção é um frame explícito até o final da partição:
SELECT created_at, amount, LAST_VALUE(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS correct_last FROM orders;Uma alternativa que evita ficar mexendo nos frames:
FIRST_VALUEcom a ordenação invertida, ouMAX(...) OVER (PARTITION BY ...)semORDER BY(aí o frame cobre a partição inteira).Lembre-se de três coisas: sempre adicione um critério de desempate ao
ORDER BY; o frame padrão éRANGE ... CURRENT ROW, não "a partição inteira"; e paraLAST_VALUEvocê quase sempre vai querer escrever explicitamenteROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.