sqlpostgresqlwindow-functionsanalytics

Frames de janela em SQL: ROWS/RANGE, totais acumulados e a armadilha do LAST_VALUE

Entenda como funcionam os frames ROWS e RANGE BETWEEN, construa totais acumulados e médias móveis, e evite a clássica armadilha do frame padrão do LAST_VALUE.

4 min de leituraReferênciasql · postgresql · window-functions · analytics

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

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

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

Pratique com exercícios reais

Resolva exercícios no treinador de SQL com correção instantânea e dicas.

Abrir o treinador