Data Engineering

SQL Window Functions Tutorial: Rank, Aggregate, Vergleich

Lerne SQL Window Functions mit ausführbaren Beispielen — Rankings, laufende Summen, LAG/LEAD und typische Fallen in PostgreSQL, Spark SQL und BigQuery.

Harbinger Team31. März 202610 Min. LesezeitAktualisiert 15.5.2026
  • sql
  • window-functions
  • postgresql
  • spark-sql
  • bigquery
  • analytics
  • tutorial
Inhaltsverzeichnis24 Abschnitte

SQL Window Functions Tutorial: Rank, Aggregate, Vergleich

Jede:r Analyst:in läuft gegen dieselbe Wand: Du brauchst eine laufende Summe, eine Zeilennummer oder einen Vergleich zur Vorzeile — und GROUP BY kollabiert das Detail, das du brauchst. SQL Window Functions lösen das ohne Subqueries oder Self-Joins. Hast du sie einmal gelernt, fragst du dich, wie du je ohne sie überlebt hast.

Dieses Tutorial deckt die nützlichsten Window Functions mit ausführbaren Beispielen in PostgreSQL-Syntax ab (das meiste funktioniert identisch in Spark SQL, BigQuery Standard SQL und Snowflake SQL). Am Ende behandelst du Rankings, laufende Aggregate und Zeile-zu-Zeile-Vergleiche souverän.

TL;DR

  • Window Function: Berechnung über eine Zeilenmenge, ohne sie zu kollabieren
  • Drei Familien: Ranking (RANK, DENSE_RANK, ROW_NUMBER), Aggregate (SUM, AVG OVER), Value (LAG, LEAD, FIRST_VALUE)
  • OVER(...) definiert das Fenster — PARTITION BY und ORDER BY als Hauptbausteine
  • QUALIFY in BigQuery und Snowflake ist die saubere Alternative zu CTE-Filterung
  • Häufigste Falle: LAST_VALUE ohne expliziten Frame liefert die aktuelle Zeile

Was sind SQL Window Functions?

Eine Window Function rechnet über eine Menge von Zeilen, die mit der aktuellen Zeile in Beziehung stehen — ohne sie wie GROUP BY zu einer einzelnen Output-Zeile zu kollabieren. Die Schlüssel-Klausel ist OVER(), die das "Fenster" definiert, das jede Zeile sieht.

KonzeptGROUP BYWindow Function
Zeilen zurückEine pro GruppeJede Originalzeile
AggregationKollabiert ZeilenFügt Spalte neben Daten hinzu
Use-CaseSummary-ReportsRankings, laufende Summen, Vergleiche
Syntax-MarkerGROUP BY colfunction() OVER(...)

Anatomie eines Window-Function-Aufrufs

-- PostgreSQL
SELECT
    employee_id,
    department,
    salary,
    RANK() OVER (
        PARTITION BY department    -- defines the group (like GROUP BY, but no collapse)
        ORDER BY salary DESC       -- defines the order within the partition
    ) AS dept_salary_rank
FROM employees;
  • PARTITION BY teilt Zeilen in Gruppen (optional — weglassen, und das Fenster ist das ganze Resultset)
  • ORDER BY bestimmt die Sequenz innerhalb jeder Partition
  • Frame-Klausel (optional) schränkt weiter ein, welche Zeilen innerhalb der Partition die Function sieht

Die drei Familien der Window Functions

1. Ranking-Funktionen

Ranking-Functions weisen jeder Zeile eine Position innerhalb ihrer Partition zu.

FunktionBei GleichstandLücken nach TiesBeispiel (Werte 100, 100, 90)
ROW_NUMBER()Bricht arbiträrKeine Lücken1, 2, 3
RANK()Gleicher RangLücken nach Ties1, 1, 3
DENSE_RANK()Gleicher RangKeine Lücken1, 1, 2
NTILE(n)Verteilt auf n BucketsN/AAbhängig von n

Praxisbeispiel: Top 3 Vertriebler:innen pro Region

-- PostgreSQL
WITH ranked AS (
    SELECT
        salesperson_name,
        region,
        total_revenue,
        DENSE_RANK() OVER (
            PARTITION BY region
            ORDER BY total_revenue DESC
        ) AS revenue_rank
    FROM sales_summary
)
SELECT *
FROM ranked
WHERE revenue_rank <= 3
ORDER BY region, revenue_rank;

Warum hier DENSE_RANK? Wenn zwei Personen auf Platz 2 gleichstehen, willst du Platz 3 trotzdem sehen. RANK() würde nach einem Tie auf 4 springen und potenziell weniger als 3 Personen pro Region zurückgeben.

2. Aggregate Window Functions

Jedes Standard-Aggregat — SUM, AVG, COUNT, MIN, MAX — kann mit OVER() als Window Function genutzt werden.

Laufende Summe

-- PostgreSQL
SELECT
    order_date,
    order_amount,
    SUM(order_amount) OVER (
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM orders
ORDER BY order_date;

Gleitender Durchschnitt (7-Tage)

-- PostgreSQL
SELECT
    metric_date,
    daily_value,
    AVG(daily_value) OVER (
        ORDER BY metric_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS moving_avg_7d
FROM daily_metrics
ORDER BY metric_date;

Frame-Klauseln zählen. ROWS BETWEEN steuert exakt, welche Zeilen ins Aggregat fließen. Häufige Frames:

FrameBedeutung
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWAlle Zeilen von Start bis Aktuell (laufende Summe)
ROWS BETWEEN 6 PRECEDING AND CURRENT ROWAktuelle Zeile + 6 davor (7-Zeilen-Fenster)
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWINGAktuelle Zeile bis Ende
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROWDatums-basierter Bereich (PostgreSQL)

ROWS vs RANGE: ROWS zählt physische Zeilen. RANGE nutzt logische Werte — nützlich für Datums-Fenster, aber nicht einheitlich über Engines unterstützt. Spark SQL unterstützt RANGE mit Numerik/Datum, BigQuery Standard SQL unterstützt es für Datums-Expressions. Im Zweifel: ROWS.

3. Value-Funktionen: LAG, LEAD, FIRST_VALUE, LAST_VALUE

Diese erreichen Werte aus anderen Zeilen ohne Self-Join.

FunktionLiefert
LAG(col, n, default)Wert n Zeilen davor
LEAD(col, n, default)Wert n Zeilen danach
FIRST_VALUE(col)Erster Wert im Window-Frame
LAST_VALUE(col)Letzter Wert im Window-Frame
NTH_VALUE(col, n)n-ter Wert im Window-Frame

Monatswachstumsrate (Month-over-Month)

-- PostgreSQL
SELECT
    report_month,
    revenue,
    LAG(revenue, 1) OVER (ORDER BY report_month) AS prev_month_revenue,
    ROUND(
        (revenue - LAG(revenue, 1) OVER (ORDER BY report_month))::numeric
        / NULLIF(LAG(revenue, 1) OVER (ORDER BY report_month), 0)
        * 100, 1
    ) AS mom_growth_pct
FROM monthly_revenue
ORDER BY report_month;

Gib immer einen Default für LAG/LEAD an, wenn NULLs Probleme nach unten verursachen könnten:

-- PostgreSQL
LAG(revenue, 1, 0) OVER (ORDER BY report_month)

Erstes Kaufdatum pro Kunde

-- PostgreSQL
SELECT DISTINCT
    customer_id,
    FIRST_VALUE(order_date) OVER (
        PARTITION BY customer_id
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS first_purchase_date
FROM orders;

Aufpassen bei LAST_VALUE: Der Default-Frame ist ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, also liefert LAST_VALUE die aktuelle Zeile, wenn du nicht explizit den Frame auf ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING setzt.

Mehrere Window Functions kombinieren

Du kannst mehrere Window Functions in derselben Query nutzen. Wiederholst du dieselbe OVER()-Klausel, nutz ein Named Window, um DRY zu bleiben:

-- PostgreSQL
SELECT
    employee_id,
    department,
    salary,
    RANK() OVER dept_window AS salary_rank,
    AVG(salary) OVER dept_window AS dept_avg_salary,
    salary - AVG(salary) OVER dept_window AS diff_from_avg
FROM employees
WINDOW dept_window AS (PARTITION BY department ORDER BY salary DESC)
ORDER BY department, salary_rank;

Engine-Support für Named Windows: PostgreSQL unterstützt WINDOW-Klauseln nativ. Spark SQL 3.x auch. BigQuery Standard SQL unterstützt Named Windows nicht — du musst die OVER()-Klausel wiederholen.

Häufige Fehler und Fallen

1. Default-Frame vergessen

Wenn ORDER BY innerhalb OVER() vorhanden ist, ist der Default-Frame RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — nicht die gesamte Partition. Das überrascht bei SUM() und AVG():

-- PostgreSQL — This is a running total, NOT a partition total
SELECT SUM(amount) OVER (PARTITION BY dept ORDER BY hire_date) FROM employees;

-- This IS a partition total (no ORDER BY = entire partition as frame)
SELECT SUM(amount) OVER (PARTITION BY dept) FROM employees;

2. Window Functions in WHERE

Window Functions laufen nach WHERE. Du kannst nicht direkt darauf filtern:

-- This fails
SELECT * FROM employees WHERE RANK() OVER (ORDER BY salary DESC) <= 5;

-- Use a CTE or subquery
WITH ranked AS (
    SELECT *, RANK() OVER (ORDER BY salary DESC) AS rnk
    FROM employees
)
SELECT * FROM ranked WHERE rnk <= 5;

3. Nicht-deterministisches ROW_NUMBER

Wenn dein ORDER BY Zeilen nicht eindeutig identifiziert, weist ROW_NUMBER() Positionen unter Ties arbiträr zu — und die Reihenfolge kann sich zwischen Ausführungen ändern:

-- Non-deterministic: ties on salary get arbitrary row numbers
ROW_NUMBER() OVER (ORDER BY salary DESC)

-- Add a tiebreaker
ROW_NUMBER() OVER (ORDER BY salary DESC, employee_id ASC)

4. Performance: Fehlende Indizes

Window Functions mit ORDER BY profitieren von Indizes auf den Sortierspalten, besonders bei großen Tabellen. PARTITION BY + ORDER BY-Kombinationen funktionieren am besten mit einem Composite-Index, der beide abdeckt.

5. LAST_VALUE liefert aktuelle Zeile

Wie oben — setz immer den Frame explizit bei LAST_VALUE:

-- Correct way to get the actual last value in the partition
LAST_VALUE(salary) OVER (
    PARTITION BY department
    ORDER BY hire_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

Window Functions über SQL-Engines hinweg

FeaturePostgreSQLSpark SQLBigQuery Standard SQLSnowflake SQL
Named Windows (WINDOW)JaJa (3.x+)NeinJa
RANGE mit DatumJaJa (num/date)JaJa
NTH_VALUEJaJaJaJa
QUALIFY (Filter auf Window)NeinNeinJaJa
NTILEJaJaJaJa

BigQuery und Snowflake bieten QUALIFY als saubere Alternative zur CTE-Filterung:

-- BigQuery Standard SQL / Snowflake SQL
SELECT *
FROM sales_summary
QUALIFY DENSE_RANK() OVER (PARTITION BY region ORDER BY total_revenue DESC) <= 3;

Das eliminiert das CTE-Muster komplett — wissenswert, wenn du in diesen Engines arbeitest.

Wann Window Functions nicht das richtige Tool sind

  • Einfache Aggregation ohne Zeilendetail — nutz einfach GROUP BY.
  • Cross-Table-Lookups — das ist ein JOIN, keine Window Function.
  • Rekursive Muster (z. B. Tree-Traversal) — rekursive CTEs.
  • Sehr große Partitionen mit komplexen Frames — Memory-intensiv. Bei Millionen Zeilen pro Partition mit ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING lohnt es sich, einen Pre-Aggregations-Ansatz zu erwägen.

Quick-Reference-Cheatsheet

AufgabeFunktionWichtiges Detail
Ranking mit LückenRANK()Ties teilen Rang, nächster wird übersprungen
Ranking ohne LückenDENSE_RANK()Ties teilen Rang, nächster ist +1
Eindeutige ZeilennummerROW_NUMBER()Tiebreaker in ORDER BY ergänzen
Laufende SummeSUM() OVER (ORDER BY ...)Default-Frame passt
Gleitender DurchschnittAVG() OVER (ROWS BETWEEN n PRECEDING AND CURRENT ROW)Frame explizit setzen
Wert der VorzeileLAG(col, 1)Default angeben, um NULL zu vermeiden
Wert der NachzeileLEAD(col, 1)Default angeben, um NULL zu vermeiden
In Buckets aufteilenNTILE(n)Gleichmäßige Verteilung auf n Gruppen

Nächste Schritte

Bist du mit Window Functions vertraut, erkunde diese Muster:

  • Sessionization — nutz LAG, um Lücken in Event-Timestamps zu erkennen und Session-IDs zuzuweisen
  • DeduplicationROW_NUMBER() nach Natural Key partitioniert, gefiltert auf rnk = 1
  • Kumulative VerteilungCUME_DIST() und PERCENT_RANK() für Percentile-Berechnungen
  • Gap-and-Island-ProblemeROW_NUMBER mit Datumsarithmetik kombinieren, um aufeinanderfolgende Sequenzen zu finden

Arbeitest du mit Daten aus APIs oder CSVs und willst Window-Function-Queries direkt im Browser laufen lassen ohne Datenbank-Setup, lässt Harbinger Explorer dich Datenquellen laden und mit DuckDB-SQL abfragen — inklusive vollem Window-Function-Support — direkt aus einem Browser-Tab.

FAQ

Was ist der Unterschied zwischen RANK und DENSE_RANK?

RANK lässt Lücken nach Ties (1, 1, 3, 4), DENSE_RANK nicht (1, 1, 2, 3). Wähle DENSE_RANK, wenn du "Top N" auch bei Gleichstand zuverlässig haben willst.

Kann ich Window Functions in dbt-Models nutzen?

Ja, problemlos. Window Functions kompilieren zu Standard-SQL, und dbt führt das gegen dein Warehouse aus. Achte nur darauf, dass dein Ziel-Warehouse die benutzten Features unterstützt (z. B. QUALIFY in BigQuery/Snowflake, nicht in PostgreSQL).

Wann nehme ich GROUP BY statt einer Window Function?

Wenn du tatsächlich nur eine aggregierte Zeile pro Gruppe willst. Brauchst du Detailzeilen plus eine Aggregation (z. B. "Pro Bestellung den Anteil am Tagesumsatz"), nimm Window Functions.

Warum liefert LAST_VALUE manchmal den aktuellen Wert statt den letzten?

Weil der Default-Frame ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ist. Setz explizit ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, damit das Fenster die ganze Partition umfasst.

Weiterlesen

Stand: 15. Mai 2026.

H

Geschrieben von

Harbinger Team

Cloud-, Data- und AI-Engineer in DACH. Schreibt seit 2018 über infrastruktur­kritische Tech-Entscheidungen — keine Marketing- Folien, sondern echte Trade-offs aus Production-Workloads.

Hat dir das geholfen?

Jede Woche ein neuer Artikel über DACH-Cloud, Data und AI — direkt in dein Postfach. Kein Spam, kein Marketing-Sprech.

Kein Spam. 1-Klick-Abmeldung. Datenschutz bei Loops.so.