Mit LAG und LEAD greifst du auf den Vorgänger oder Nachfolger einer Zeile in einer Window-Partition zu — perfekt für Differenz-Berechnungen („wieviel mehr als gestern?"), Veränderungs-Erkennung oder Trend-Analysen. FIRST_VALUE und LAST_VALUE liefern die jeweils erste/letzte Zeile einer Partition. Beide sind essenzielle Werkzeuge für Reporting und Analytics.
Beispiel-Daten
CREATE TABLE daily_sales (
customer text NOT NULL,
day date NOT NULL,
sales numeric(10, 2) NOT NULL,
PRIMARY KEY (customer, day)
);
INSERT INTO daily_sales VALUES
('Alice', '2026-05-01', 100),
('Alice', '2026-05-02', 150),
('Alice', '2026-05-03', 130),
('Alice', '2026-05-04', 200),
('Bob', '2026-05-01', 80),
('Bob', '2026-05-02', 90),
('Bob', '2026-05-03', 120);LAG — den Vorgänger sehen
LAG(col, offset, default) liefert den Wert von col aus der vorherigen Zeile (innerhalb der Partition, gemäß ORDER BY im Window).
SELECT
customer,
day,
sales,
LAG(sales) OVER (PARTITION BY customer ORDER BY day) AS prev_sales,
sales - LAG(sales) OVER (PARTITION BY customer ORDER BY day) AS diff
FROM daily_sales;Output:
customer | day | sales | prev_sales | diff
---------+------------+-------+------------+------
Alice | 2026-05-01 | 100 | NULL | NULL
Alice | 2026-05-02 | 150 | 100 | 50
Alice | 2026-05-03 | 130 | 150 | -20
Alice | 2026-05-04 | 200 | 130 | 70
Bob | 2026-05-01 | 80 | NULL | NULL
Bob | 2026-05-02 | 90 | 80 | 10
Bob | 2026-05-03 | 120 | 90 | 30Pro Kunde startet die Partition neu — Alice am 1. Mai hat keinen Vorgänger (NULL). Innerhalb der Partition ist LAG(sales) der sales-Wert der vorigen Zeile (= Vortag).
LAG mit Offset:
-- Wert von vor 7 Tagen
LAG(sales, 7) OVER (PARTITION BY customer ORDER BY day)Standard-Offset ist 1. Mit drittem Argument: Default-Wert, wenn kein Vorgänger existiert (statt NULL):
LAG(sales, 1, 0) OVER (...) -- erste Zeile der Partition: 0 statt NULLLEAD — den Nachfolger sehen
LEAD(col, offset, default) ist das Spiegelbild von LAG — der nächste Wert.
SELECT
customer,
day,
sales,
LEAD(sales) OVER (PARTITION BY customer ORDER BY day) AS next_sales,
LEAD(day) OVER (PARTITION BY customer ORDER BY day) AS next_day
FROM daily_sales;Output:
customer | day | sales | next_sales | next_day
---------+------------+-------+------------+------------
Alice | 2026-05-01 | 100 | 150 | 2026-05-02
Alice | 2026-05-02 | 150 | 130 | 2026-05-03
Alice | 2026-05-03 | 130 | 200 | 2026-05-04
Alice | 2026-05-04 | 200 | NULL | NULL
Bob | 2026-05-01 | 80 | 90 | 2026-05-02
...Letzte Zeile der Partition hat keinen Nachfolger → NULL. Für „wann war die nächste Bestellung?"-Patterns sehr nützlich.
FIRST_VALUE und LAST_VALUE
Liefern den ersten/letzten Wert der Partition:
SELECT
customer,
day,
sales,
FIRST_VALUE(sales) OVER (PARTITION BY customer ORDER BY day) AS first_day_sales,
FIRST_VALUE(day) OVER (PARTITION BY customer ORDER BY day) AS first_day,
LAST_VALUE(sales) OVER (
PARTITION BY customer ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_day_sales
FROM daily_sales;Output:
customer | day | sales | first_day_sales | first_day | last_day_sales
---------+------------+-------+-----------------+------------+----------------
Alice | 2026-05-01 | 100 | 100 | 2026-05-01 | 200
Alice | 2026-05-02 | 150 | 100 | 2026-05-01 | 200
Alice | 2026-05-03 | 130 | 100 | 2026-05-01 | 200
Alice | 2026-05-04 | 200 | 100 | 2026-05-01 | 200
Bob | 2026-05-01 | 80 | 80 | 2026-05-01 | 120
...Pro Zeile ist first_day_sales der sales-Wert vom ersten Tag der Partition. first_day der erste Tag selbst.
Wichtig: Das LAST_VALUE braucht ein explizites Frame (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING). Sonst gilt der Default-Frame mit ORDER BY (= bis CURRENT ROW), und LAST_VALUE liefert die aktuelle Zeile statt der echten letzten — klassische Falle.
Praxis-Patterns
Wachstumsrate vs. Vortag
SELECT
customer,
day,
sales,
ROUND(
100.0 * (sales - LAG(sales) OVER w) / NULLIF(LAG(sales) OVER w, 0),
2
) AS growth_pct
FROM daily_sales
WINDOW w AS (PARTITION BY customer ORDER BY day);NULLIF(LAG(sales) OVER w, 0) schützt vor Division durch Null. Output: prozentuale Veränderung Tag-zu-Tag pro Kunde.
Lücken in Zeitreihen finden
SELECT
customer,
day,
LAG(day) OVER (PARTITION BY customer ORDER BY day) AS prev_day,
day - LAG(day) OVER (PARTITION BY customer ORDER BY day) AS gap_days
FROM daily_sales
WHERE customer = 'Alice';Wenn gap_days > 1, hat der Kunde an mindestens einem Tag dazwischen keinen Eintrag. Klassisch für „Lücken im Daily-Report" oder „Inaktive Tage".
Erste vs. aktuelle Werte
SELECT
customer,
day,
sales,
FIRST_VALUE(sales) OVER (PARTITION BY customer ORDER BY day) AS initial_sales,
sales - FIRST_VALUE(sales) OVER (PARTITION BY customer ORDER BY day) AS change_from_start
FROM daily_sales;Praktisch für „Wachstum vom ersten Tag" oder „Veränderung gegenüber Initial-Stand".
Vergleich zu Self-Join
Vor Window Functions baute man dasselbe mit Self-Joins:
SELECT
t.customer,
t.day,
t.sales,
prev.sales AS prev_sales,
t.sales - prev.sales AS diff
FROM daily_sales t
LEFT JOIN daily_sales prev
ON prev.customer = t.customer
AND prev.day = t.day - INTERVAL '1 day';Funktioniert, hat aber Probleme:
- Lücken im Datum: wenn ein Tag fehlt, gibt's keinen Match
- Performance: Self-Join über große Tabelle ist meist teurer als Window
- Lesbarkeit: das Ziel „Vorgänger lesen" ist nicht so klar wie
LAG
Window Functions bauen die Reihenfolge über ORDER BY selbst — fehlende Tage sind kein Problem.
Besonderheiten
LAST_VALUE braucht explizites Frame.
Default-Frame mit ORDER BY ist „bis CURRENT ROW" — also liefert LAST_VALUE die aktuelle Zeile, nicht die letzte der Partition. Korrekt: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING dranhängen. Häufiger Bug-Auslöser.
LAG/LEAD brauchen ORDER BY im Window.
Ohne ORDER BY ist die Reihenfolge der Zeilen in der Partition undefiniert — LAG liefert dann irgendeinen Wert. Postgres lehnt das nicht ab, das Resultat ist nur nicht-deterministisch. Immer ORDER BY mit klarem Tiebreaker.
Default-Wert als drittes Argument spart COALESCE.
Statt COALESCE(LAG(x), 0) schreibst du LAG(x, 1, 0) — dritter Parameter ist der Wert für die erste Zeile (oder allgemein: wenn kein Vorgänger existiert). Lesbarer und etwas schneller.
Lücken in Zeitreihen sichtbar machen.
LAG(day) OVER (...) - day zeigt, wieviele Tage zwischen aktueller und vorheriger Zeile liegen. Bei lückenlosen Daten immer -1. Bei Lücken größer. Praktisch für „inaktive Phasen" oder „verpasste Tage" in Reports.
FIRST_VALUE + ORDER BY DESC = letzter Wert.
Wer mit LAST_VALUE und Frame-Hassle nicht arbeiten will: FIRST_VALUE(x) OVER (PARTITION BY g ORDER BY d DESC) liefert den Wert mit höchstem d — in der Praxis der „letzte". Manchmal lesbarer als die LAST_VALUE-Variante.
Window-Functions in CTEs für komplexere Logik.
Wenn du auf die Window-Werte filtern oder daraus weiter aggregieren willst (z. B. „nur Tage mit Wachstum > 10 %"), pack die Window-Function in eine CTE/Subquery und filter in der äußeren Query. WHERE auf einer Window-Function direkt geht nicht.