Der Frame innerhalb einer Window Function bestimmt, welche Zeilen das Aggregat tatsächlich sieht — nicht alle der Partition, sondern eine definierte Untermenge relativ zur aktuellen Zeile. Drei Frame-Modi (ROWS, RANGE, GROUPS) plus die Default-Regeln machen das Thema subtil. Dieser Artikel klärt, wann was greift.

Default-Frames — die häufige Stolperfalle

Was der Frame ohne explizite Angabe ist, hängt davon ab, ob ORDER BY im Window steht:

Window-DefinitionDefault-Frame
OVER ()komplette Partition (alle Zeilen)
OVER (PARTITION BY x)komplette Partition
OVER (ORDER BY y)von Anfang bis aktuelle Zeile (laufende Aggregate)
OVER (PARTITION BY x ORDER BY y)pro Partition: von Anfang bis aktuelle Zeile

Konkret heißt das: sobald du ORDER BY im Window setzt, bekommst du automatisch ein laufendes Aggregat — auch wenn du das gar nicht wolltest. Klassische Verwirrung.

SQL Beispiel: ohne und mit ORDER BY
myapp=> SELECT
          id,
          total,
          sum(total) OVER ()                   AS total_all,
          sum(total) OVER (ORDER BY id)        AS running_sum
        FROM orders;

 id |  total | total_all | running_sum
----+--------+-----------+-------------
  1 |  99.95 |    409.65 |       99.95
  2 |  49.95 |    409.65 |      149.90
  3 | 199.95 |    409.65 |      349.85
  4 |  29.95 |    409.65 |      379.80
  5 |  29.85 |    409.65 |      409.65

total_all ist konstant — das Window ist die ganze Partition. running_sum wächst — das Window ist „von Anfang bis aktuelle Zeile" (Default mit ORDER BY).

ROWS — physische Zeilen-Offsets

Mit ROWS definierst du den Frame über die Anzahl Zeilen vor/nach der aktuellen:

SQL Letzte 3 Zeilen (inkl. aktueller)
SELECT
    id,
    total,
    avg(total) OVER (
        ORDER BY id
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    )::numeric(10,2) AS avg_last_3
FROM orders;

Output:

SQL
 id |  total  | avg_last_3
----+---------+------------
  1 |   99.95 |      99.95   <- nur Zeile 1
  2 |   49.95 |      74.95   <- (99.95 + 49.95) / 2
  3 |  199.95 |     116.62   <- (99.95 + 49.95 + 199.95) / 3
  4 |   29.95 |      93.28   <- (49.95 + 199.95 + 29.95) / 3
  5 |   29.85 |      86.58   <- (199.95 + 29.95 + 29.85) / 3

Klassischer Use-Case: gleitender Durchschnitt über N Werte (z. B. 7-Tage-Average für Sensor-Daten).

Frame-Grenzen-Vokabular:

KlauselBedeutung
UNBOUNDED PRECEDINGvon Anfang der Partition
n PRECEDINGn Zeilen davor
CURRENT ROWaktuelle Zeile
n FOLLOWINGn Zeilen danach
UNBOUNDED FOLLOWINGbis Ende der Partition

Häufige Frame-Patterns

SQL Typische Frame-Definitionen
-- Laufende Summe (Default mit ORDER BY)
sum(x) OVER (ORDER BY y)
-- entspricht:
sum(x) OVER (ORDER BY y ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

-- Gleitender Durchschnitt über 7 Tage
avg(x) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

-- Aktuelle plus nächste 5 Zeilen
sum(x) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND 5 FOLLOWING)

-- Komplette Partition (kein laufendes Aggregat)
sum(x) OVER (PARTITION BY group ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)

RANGE — Wert-basierte Bereiche

RANGE arbeitet nicht mit Zeilen-Offsets, sondern mit Wert-Differenzen der ORDER BY-Spalte:

SQL RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
SELECT
    day,
    sales,
    sum(sales) OVER (
        ORDER BY day
        RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
    ) AS sales_last_7_days
FROM daily_sales
ORDER BY day;

Bei RANGE mit INTERVAL '6 days' PRECEDING greift das Window auf alle Zeilen, deren day zwischen day - 6 days und day liegt — egal wie viele Zeilen dazwischen sind. Bei lückenhaften Daten (manche Tage ohne Sales-Eintrag) ist das oft, was man eigentlich will.

RANGE (ohne explizite Grenzen) hat einen Sonderfall: Default ist RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — aber CURRENT ROW bedeutet hier „alle Zeilen mit gleichem ORDER BY-Wert". Das ist anders als bei ROWS, wo CURRENT ROW genau eine Zeile ist.

ROWS vs. RANGE — der subtile Unterschied

Das wird klar an einem Beispiel mit Duplikaten in der ORDER BY-Spalte:

SQL Mehrere Zeilen mit gleichem day
-- daily_sales mit 2 Einträgen pro Tag (z. B. 'morgens' und 'abends')
SELECT
    day,
    shift,
    sales,
    sum(sales) OVER (ORDER BY day ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)  AS sum_rows,
    sum(sales) OVER (ORDER BY day RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)  AS sum_range
FROM daily_sales;

Output:

SQL
    day     |  shift  | sales | sum_rows | sum_range
------------+---------+-------+----------+-----------
 2026-05-01 | morning |   100 |      100 |       300
 2026-05-01 | evening |   200 |      300 |       300
 2026-05-02 | morning |   150 |      450 |       650
 2026-05-02 | evening |   200 |      650 |       650

ROWS schließt jede Zeile einzeln ein — sum_rows wächst pro Zeile. RANGE behandelt alle Zeilen mit gleichem day als „ein Punkt" — beide Zeilen pro Tag haben denselben sum_range-Wert.

Faustregel:

  • ROWS für Zeilenzählung (z. B. „letzte 10 Einträge")
  • RANGE für Wert-Gleichheit und Zeit-Bereiche mit Lücken-Toleranz

GROUPS — Peer-Gruppen (PG 11+)

GROUPS ist eine Mischform: zählt nicht Zeilen, sondern „Peer-Gruppen" (gleiche ORDER BY-Werte als eine Gruppe):

SQL
SELECT
    day,
    shift,
    sales,
    sum(sales) OVER (
        ORDER BY day
        GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW
    ) AS sum_today_and_yesterday
FROM daily_sales;

GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW heißt: die aktuelle Tagesgruppe plus die eine davor. Bei zwei Schichten pro Tag fasst es die Gruppen je Tag zusammen.

In der Praxis selten gebraucht — Standard ist meist ROWS oder RANGE. GROUPS erlaubt aber elegante Lösungen für Peer-basierte Aggregate.

EXCLUDE — Zeilen vom Frame ausschließen

PG 11+: mit EXCLUDE lassen sich bestimmte Zeilen aus dem Frame ausklammern:

KlauselWirkung
EXCLUDE NO OTHERSDefault — alles bleibt
EXCLUDE CURRENT ROWaktuelle Zeile raus
EXCLUDE GROUPaktuelle Peer-Gruppe raus
EXCLUDE TIESnur die aktuelle Zeile bleibt von der Peer-Gruppe
SQL Durchschnitt 'aller anderen' Zeilen
SELECT
    id,
    total,
    avg(total) OVER (
        ORDER BY id
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
        EXCLUDE CURRENT ROW
    )::numeric(10,2) AS avg_others
FROM orders;

Pro Zeile der Durchschnitt aller anderen Zeilen — die aktuelle ist ausgeschlossen. Praktisch für „wie passt diese Zeile in den Rest?"-Vergleiche.

Häufige Stolperfallen

ORDER BY ohne explizites Frame = laufendes Aggregat.

Klassischer Bug: sum(x) OVER (PARTITION BY group ORDER BY date) und man wundert sich, warum die Werte pro Zeile unterschiedlich sind, statt konstant pro Gruppe. Default-Frame mit ORDER BY ist „bis CURRENT ROW". Wer die ganze Gruppe will: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING explizit dranhängen.

RANGE mit BETWEEN n PRECEDING braucht passende Typen.

RANGE BETWEEN 7 PRECEDING ... funktioniert nur, wenn der Wert numerisch ist UND zur ORDER-BY-Spalte passt. Für Datums-Spalten brauchst du INTERVAL '7 days'. Bei String-Spalten geht's gar nicht — die haben keine sinnvolle Differenz-Operation.

UNBOUNDED FOLLOWING = Window über die ganze Zukunft.

ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING greift auf alle künftigen Zeilen der Partition. Praktisch für „verbleibendes Total" oder „prozentualer Restanteil". Klingt seltsam (Window in die „Zukunft"), aber Postgres kann das prima — der Plan macht einen Sort und arbeitet die Zeilen rückwärts ab.

RANGE braucht Sortier-Eindeutigkeit für UNBOUNDED FOLLOWING.

RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING mit Duplikaten in der ORDER-BY-Spalte fasst alle Peer-Gruppen-Zeilen zur „aktuellen" zusammen. Effekt: das Window ist möglicherweise größer als erwartet. ROWS ist hier oft die deterministischere Wahl.

Frame ist Pflicht, wenn die Default-Regeln nicht passen.

Wenn du sicher gehen willst, was der Frame ist, schreib's hin. Beispiel: OVER (PARTITION BY g ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) ist explizit „ganze Partition trotz ORDER BY". Lesbarer als die Default-Regel auswendig zu kennen.

Window Functions brauchen meist einen Sort.

PARTITION BY und ORDER BY im Window erzwingen oft einen Sort-Schritt im Plan. Mit passendem Index (z. B. (partition_col, order_col)) kann Postgres den Sort einsparen. Bei großen Tabellen: index-passung prüfen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Aggregation & Window Functions

Zur Übersicht