PIVOT in SQL: Zeilen in Spalten verwandeln
Angenommen, in der Datenbank liegen Produktverkäufe nach Quartal.
Ein Standard-GROUP BY liefert dieses Ergebnis:
Für den Report willst du es aber so haben, mit Quartalen als Spalten:
So eine Umwandlung von Zeilen in Spalten heißt PIVOT. Im Artikel schauen wir, wie sich das mit reinen SQL-Mitteln umsetzen lässt, am Beispiel einer Lieferdienst-Datenbank.
Das Problem: „langes" Format
In dieser Datenbank gibt es die Tabelle events mit Nutzeraktionen auf verschiedenen Plattformen. Zählen wir die Ereignisse nach Typ und Plattform:
MySQL 8.1SELECT event_name, platform, COUNT(*) AS cnt FROM events GROUP BY event_name, platform ORDER BY event_name, platform;
Das ist das sogenannte „lange" Format. Jede Kombination „Ereignis + Plattform" belegt eine eigene Zeile. Um iOS und Android für ein Ereignis zu vergleichen, muss man die passenden Zeilen mit dem Auge suchen.
Wir wollen die Daten aber so sehen:
Die Plattformen sind Spalten geworden, und jeder Ereignistyp belegt eine Zeile. Genau dieses Ergebnis wollen wir.
Wie man PIVOT baut: Algorithmus
Bevor du den Query schreibst, beantworte vier Fragen:
- Was werden die Zeilen? event_name (Ereignistyp)
- Was werden die Spalten? platform (iOS, Android, Web)
- Was steht als Wert in den Zellen? COUNT(*) (Anzahl der Ereignisse)
CASE + GROUP BY: der universelle Weg
Dieser Ansatz funktioniert in jedem DBMS: MySQL, PostgreSQL, SQLite, SQL Server.
MySQL 8.1SELECT event_name, SUM(CASE WHEN platform = 'iOS' THEN 1 ELSE 0 END) AS iOS, SUM(CASE WHEN platform = 'Android' THEN 1 ELSE 0 END) AS Android, SUM(CASE WHEN platform = 'Web' THEN 1 ELSE 0 END) AS Web FROM events GROUP BY event_name ORDER BY event_name;
Aber wie funktioniert das? Gehen wir Schritt für Schritt durch.
Was im Inneren passiert
Um die Mechanik zu verstehen, lassen wir SUM und GROUP BY weg und schauen, was CASE für jede einzelne Zeile ausrechnet:
MySQL 8.1SELECT event_name, platform, CASE WHEN platform = 'iOS' THEN 1 ELSE 0 END AS "iOS", CASE WHEN platform = 'Android' THEN 1 ELSE 0 END AS "Android", CASE WHEN platform = 'Web' THEN 1 ELSE 0 END AS "Web" FROM events WHERE event_name IN ('app_open', 'purchase') LIMIT 6;
Jedes CASE prüft die Plattform und setzt 1 bei Treffer, sonst 0. Hier ein Auszug aus dem Ergebnis zur Veranschaulichung:
Hier siehst du einen Teil der Zeilen für zwei Gruppen: app_open und purchase. Jede Zeile bekommt genau in einer Spalte eine 1, in den anderen 0. Eine Zeile mit platform = 'iOS' zum Beispiel bekommt die 1 nur in iOS.
Dann gruppiert GROUP BY die Zeilen nach event_name, und SUM addiert die Werte in jeder Gruppe. So viele Einsen in der Spalte iOS zusammenkommen, so viele Ereignisse kamen von iOS.
Achte auf das ELSE 0 im CASE: ohne es bekommen die Zeilen ohne Treffer NULL statt 0, und das Ergebnis von SUM kann am Ende ebenfalls NULL sein. Mehr dazu im Abschnitt „NULL-Falle" weiter unten.
Warum SUM und nicht COUNT? SUM ist praktischer: Du tauschst einfach die 1 gegen amount aus und zählst statt Anzahl die Summen. Genau das machen wir im nächsten Beispiel.
FILTER: die elegante Alternative
PostgreSQL unterstützt die Konstruktion FILTER, die dasselbe macht, sich aber deutlich sauberer liest:
MySQL 8.1SELECT event_name, COUNT(*) FILTER (WHERE platform = 'iOS') AS iOS, COUNT(*) FILTER (WHERE platform = 'Android') AS Android, COUNT(*) FILTER (WHERE platform = 'Web') AS Web FROM events GROUP BY event_name ORDER BY event_name;
Vergleiche: statt SUM(CASE WHEN platform = 'iOS' THEN 1 ELSE 0 END) reicht COUNT(*) FILTER (WHERE platform = 'iOS').
CASE rechnet pro Zeile einen Wert aus, und SUM ist nötig, um die Einsen zu summieren. FILTER arbeitet anders: es schneidet die überflüssigen Zeilen vor der Aggregation weg. Deshalb kann man ein normales COUNT(*) nutzen, das nur die Zeilen der gewünschten Plattform zählt.
FILTER arbeitet mit jeder Aggregatfunktion: COUNT, SUM, AVG, MIN, MAX.
Von Anzahlen zu Summen
Bisher haben wir die Anzahl der Ereignisse gezählt. Was, wenn wir Geldsummen brauchen? Ersetze einfach die 1 durch eine echte Spalte, etwa total_amount. Nehmen wir eine andere Tabelle aus derselben Datenbank, orders, und bauen einen Report mit Umsatz nach Bestellstatus für jeden Monat:
MySQL 8.1SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(CASE WHEN status = 'delivered' THEN total_amount ELSE 0 END) AS delivered, SUM(CASE WHEN status = 'canceled' THEN total_amount ELSE 0 END) AS canceled, SUM(CASE WHEN status = 'refunded' THEN total_amount ELSE 0 END) AS refunded FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2024-07-01' GROUP BY month ORDER BY month;
Hier passt FILTER perfekt, der Code wird sehr knapp:
MySQL 8.1SELECT to_char(order_date, 'YYYY-MM') AS month, SUM(total_amount) FILTER (WHERE status = 'delivered') AS delivered, SUM(total_amount) FILTER (WHERE status = 'canceled') AS canceled, SUM(total_amount) FILTER (WHERE status = 'refunded') AS refunded FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2024-07-01' GROUP BY month ORDER BY month;
Das Prinzip ist dasselbe: CASE WHEN filtert die Zeilen nach Status, und SUM rechnet statt der Einsen die Summe von total_amount. In den Zeilen stehen die Monate, in den Spalten die Status, und in den Zellen der Umsatz.
Das Prinzip ist dasselbe: FILTER schneidet die Zeilen nach Status weg, und SUM summiert total_amount nur für die passenden Bestellungen. In den Zeilen stehen die Monate, in den Spalten die Status, und in den Zellen der Umsatz.
Worauf du achten solltest
Die NULL-Falle
In den Beispielen oben haben wir ELSE 0 im CASE verwendet, um ein numerisches Ergebnis zu garantieren. Schreibst du CASE aber ohne ELSE:
MySQL 8.1SUM(CASE WHEN status = 'refunded' THEN total_amount END)
…und gibt es in einem Monat keine einzige Rückerstattung, gibt CASE für alle Zeilen NULL zurück, und SUM aus lauter NULL liefert NULL. Das ist kritisch, wenn anschließend gerechnet wird: 100 - NULL = NULL.
Vermeiden lässt sich das auf zwei Wegen:
-
ELSE 0 direkt im CASE ergänzen (wie in den Beispielen oben):
MySQL 8.1SUM(CASE WHEN status = 'refunded' THEN total_amount ELSE 0 END) -
In COALESCE einpacken. Nützlich, wenn in der Spalte ohnehin NULL vorkommen können:
MySQL 8.1COALESCE(SUM(CASE WHEN status = 'refunded' THEN total_amount END), 0)
Die Spaltenwerte muss man vorher kennen
Alle Ansätze verlangen, dass du die Werte, die zu Spalten werden ('iOS', 'Android', 'Web'), explizit aufzählst. Taucht in den Daten eine neue Plattform auf, musst du den Query manuell aktualisieren.
Der einzige Ausweg: ein Skript auf der Applikationsseite (Python, PHP, JS) oder dynamisches SQL (Query-String über PREPARE / Stored Procedures zusammenbauen). Mit reinem, statischem SQL lässt sich das nicht lösen.
In den meisten echten Aufgaben ist die Menge der Kategorien bekannt und ändert sich selten, daher ist Hardcoding völlig in Ordnung.
crosstab(): eingebautes PIVOT in PostgreSQL
PostgreSQL stellt die Funktion crosstab() aus dem Extension tablefunc bereit. Sie nimmt einen Query und klappt Zeilen automatisch in Spalten um:
MySQL 8.1CREATE EXTENSION IF NOT EXISTS tablefunc; SELECT * FROM crosstab( $$ SELECT event_name, platform, COUNT(*) FROM events GROUP BY event_name, platform ORDER BY event_name, platform $$, $$ VALUES ('Android'), ('iOS'), ('Web') $$ ) AS ct(event_name TEXT, "Android" BIGINT, "iOS" BIGINT, "Web" BIGINT);
Das erste Argument: ein Query, der drei Spalten liefert (Zeile, Spalte und Wert). Das zweite Argument: die Liste der Werte, die zu Spalten werden. Das Ergebnis muss in AS ct(...) mit Typen beschrieben werden.
crosstab() ist praktisch, wenn man viele Spalten hat und keine Lust auf ein Dutzend CASE WHEN hat. Es hat aber Nachteile: man muss das Extension installieren, die Typen explizit angeben, und die Syntax ist beim ersten Lesen weniger offensichtlich. Für die meisten Aufgaben sind FILTER oder CASE + GROUP BY einfacher und nachvollziehbarer.
Zusammengefasst
In MySQL ist CASE + GROUP BY der einzige Weg, eine PIVOT-Tabelle mit reinem SQL zu bauen. Der Ansatz ist universell, funktioniert „out of the box" und liest sich gut. Bei vielen Spalten wird der Query wortreich, aber in den meisten Reports gibt es nicht mehr als ein gutes Dutzend Kategorien.
Kurz das Vorgehen:
- Festlegen, was zu Zeilen, Spalten und Werten wird.
- CASE WHEN für jede Spalten-Variante schreiben.
- In SUM (oder COUNT) einpacken und nach den Zeilen gruppieren.
- ELSE 0 nicht vergessen, um NULL zu vermeiden.
In PostgreSQL stehen drei Ansätze zur Verfügung:
Kurz das Vorgehen:
- Festlegen, was zu Zeilen, Spalten und Werten wird.
- Den Ansatz wählen: FILTER in PostgreSQL, CASE + GROUP BY für Portabilität.
- ELSE 0 (für CASE) oder COALESCE nicht vergessen, um NULL zu vermeiden.
Passende Artikel
ROW_NUMBER vs RANK vs DENSE_RANK in SQL: der Unterschied an einem Beispiel
Drei Ranking-Funktionen, eine Abfrage — und der Unterschied ist sichtbar
COALESCE in SQL: Was es ist und wie es funktioniert — Beispiele | SQL Academy
Das erste Nicht-NULL-Argument, und warum NULLIF dazugehört
CTE in SQL: Was eine Common Table Expression (WITH) ist — Beispiele
Unterabfragen mit Namen, Schrittketten und Rekursion