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-Definition | Default-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.
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.65total_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:
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:
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) / 3Klassischer Use-Case: gleitender Durchschnitt über N Werte (z. B. 7-Tage-Average für Sensor-Daten).
Frame-Grenzen-Vokabular:
| Klausel | Bedeutung |
|---|---|
UNBOUNDED PRECEDING | von Anfang der Partition |
n PRECEDING | n Zeilen davor |
CURRENT ROW | aktuelle Zeile |
n FOLLOWING | n Zeilen danach |
UNBOUNDED FOLLOWING | bis Ende der Partition |
Häufige Frame-Patterns
-- 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:
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:
-- 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:
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 | 650ROWS 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:
ROWSfür Zeilenzählung (z. B. „letzte 10 Einträge")RANGEfü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):
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:
| Klausel | Wirkung |
|---|---|
EXCLUDE NO OTHERS | Default — alles bleibt |
EXCLUDE CURRENT ROW | aktuelle Zeile raus |
EXCLUDE GROUP | aktuelle Peer-Gruppe raus |
EXCLUDE TIES | nur die aktuelle Zeile bleibt von der Peer-Gruppe |
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.