GROUP BY ist das Werkzeug für jede Frage der Form „pro X die Anzahl/Summe/Durchschnitt von Y" — pro Kunde der Umsatz, pro Tag die Bestellungen, pro Produkt-Kategorie der Bestand. Mit HAVING filterst du anschließend Gruppen, nicht einzelne Zeilen. Dieser Artikel klärt die Mechanik mit Beispielen aus einem realistischen E-Commerce-Schema.
Beispiel-Schema
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
country text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
total numeric(10, 2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO customers (name, country) VALUES
('Alice', 'DE'),
('Bob', 'DE'),
('Carol', 'AT'),
('David', 'CH');
INSERT INTO orders (customer_id, total, status) VALUES
(1, 99.95, 'paid'),
(1, 49.95, 'paid'),
(1, 19.95, 'pending'),
(2, 199.95,'paid'),
(3, 9.95, 'paid'),
(3, 29.95, 'cancelled'),
(4, 79.95, 'paid');GROUP BY — Grundform
Die Aufgabe: pro Kunde wissen, wie viele Bestellungen er hat und was er insgesamt ausgegeben hat:
SELECT
customer_id,
count(*) AS order_count,
sum(total) AS total_spent
FROM orders
GROUP BY customer_id
ORDER BY total_spent DESC;Output:
customer_id | order_count | total_spent
-------------+-------------+-------------
2 | 1 | 199.95
1 | 3 | 169.85
4 | 1 | 79.95
3 | 2 | 39.90GROUP BY customer_id fasst alle Zeilen mit derselben customer_id zu einer Gruppe zusammen. count(*) und sum(total) rechnen pro Gruppe einen Wert aus.
Regel: Jede Spalte in SELECT muss entweder in GROUP BY stehen oder in einer Aggregat-Funktion vorkommen. Sonst Fehler:
myapp=> SELECT customer_id, total FROM orders GROUP BY customer_id;
ERROR: column "orders.total" must appear in the GROUP BY clause
or be used in an aggregate functionPostgres weiß sonst nicht: welchen total-Wert soll er für eine Gruppe von Bestellungen anzeigen? Den ersten? Den größten? Der Anwender muss sich entscheiden — entweder gruppieren oder aggregieren.
Mehrere Spalten gruppieren
SELECT
customer_id,
status,
count(*) AS count,
sum(total) AS total
FROM orders
GROUP BY customer_id, status
ORDER BY customer_id, status;Output:
customer_id | status | count | total
-------------+-----------+-------+--------
1 | paid | 2 | 149.90
1 | pending | 1 | 19.95
2 | paid | 1 | 199.95
3 | cancelled | 1 | 29.95
3 | paid | 1 | 9.95
4 | paid | 1 | 79.95Eine Zeile pro Kombination der Group-By-Spalten. customer_id = 1 hat zwei Zeilen — eine pro status-Wert.
GROUP BY mit JOIN
Häufiger realer Fall: Daten aus mehreren Tabellen pro Gruppe aggregieren.
SELECT
c.id,
c.name,
count(o.id) AS order_count,
COALESCE(sum(o.total),0) AS total_spent
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY total_spent DESC;Output:
id | name | order_count | total_spent
----+-------+-------------+-------------
2 | Bob | 1 | 199.95
1 | Alice | 3 | 169.85
4 | David | 1 | 79.95
3 | Carol | 2 | 39.90Drei wichtige Details:
LEFT JOINstattINNER JOIN— Kunden ohne Bestellung sollen mitcount = 0auftauchen.count(o.id)stattcount(*)—count(*)zählt jede Zeile (auch die mit NULL aus dem LEFT JOIN);count(o.id)zählt nur nicht-NULL-Treffer.COALESCE(sum(...), 0)—sumgibt für eine Gruppe ohne WerteNULLzurück, nicht0.COALESCEmacht's zu0für Reports.
HAVING — Gruppen filtern
WHERE filtert vor der Gruppierung — auf einzelnen Zeilen. HAVING filtert nach der Gruppierung — auf den fertigen Gruppen.
SELECT
customer_id,
count(*) AS order_count,
sum(total) AS total_spent
FROM orders
GROUP BY customer_id
HAVING sum(total) > 100
ORDER BY total_spent DESC;Output:
customer_id | order_count | total_spent
-------------+-------------+-------------
2 | 1 | 199.95
1 | 3 | 169.85HAVING sum(total) > 100 filtert die Gruppen heraus, deren Summe ≤ 100 ist.
WHERE oder HAVING?
Beide filtern, aber an unterschiedlichen Punkten:
| Klausel | Wirkt | Beispiel |
|---|---|---|
WHERE | auf einzelne Zeilen vor GROUP BY | WHERE status = 'paid' |
HAVING | auf fertige Gruppen | HAVING sum(total) > 100 |
Oft kombiniert man beide:
SELECT
customer_id,
count(*) AS paid_orders,
sum(total) AS paid_total
FROM orders
WHERE status = 'paid' -- nur paid-Zeilen reingruppieren
GROUP BY customer_id
HAVING count(*) >= 2 -- nur Kunden mit mind. 2 paid-Orders
ORDER BY paid_total DESC;Logische Reihenfolge der Auswertung:
FROM— QuelltabellenWHERE— Zeilen filternGROUP BY— GruppierenHAVING— Gruppen filternSELECT— Spalten ausgeben (inkl. Aggregaten)ORDER BY— sortierenLIMIT— kürzen
Aliasse in GROUP BY und HAVING
Spalten-Aliasse aus SELECT sind in GROUP BY (Postgres-spezifisch erlaubt) und ORDER BY (Standard) nutzbar — in WHERE und HAVING aber nicht:
SELECT
country,
count(*) AS num_customers
FROM customers
GROUP BY country -- mit Spaltenname
ORDER BY num_customers; -- mit Alias (geht überall)In HAVING musst du den Aggregat-Ausdruck wiederholen oder eine Subquery nutzen:
-- FUNKTIONIERT NICHT (in Standard-SQL):
-- HAVING num_customers > 1
-- Korrekt — Ausdruck wiederholen:
SELECT country, count(*) AS num_customers
FROM customers
GROUP BY country
HAVING count(*) > 1;Praxis-Beispiele
Top-3-Kunden nach Umsatz
SELECT
c.name,
sum(o.total) AS revenue
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
GROUP BY c.id, c.name
ORDER BY revenue DESC
LIMIT 3;Bestellungen pro Tag (für Charts)
SELECT
date_trunc('day', created_at)::date AS day,
count(*) AS orders,
sum(total) AS revenue
FROM orders
WHERE status = 'paid'
AND created_at >= now() - interval '30 days'
GROUP BY day
ORDER BY day;date_trunc('day', ...) rundet einen Timestamp auf den Tagesanfang — sehr nützliches Pattern für Reports.
Pro-Land-Zusammenfassung
SELECT
c.country,
count(DISTINCT c.id) AS customers,
count(o.id) AS orders,
COALESCE(sum(o.total), 0) AS revenue,
COALESCE(avg(o.total)::numeric(10,2), 0) AS avg_order
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
GROUP BY c.country
ORDER BY revenue DESC;count(DISTINCT c.id) zählt eindeutige Kunden — wichtig, weil ein Kunde durch den JOIN sonst pro Bestellung gezählt würde.
Interessantes
count(*) vs. count(col) — der Unterschied bei NULL.
count(*) zählt alle Zeilen einer Gruppe (auch NULL). count(col) zählt nur Zeilen, in denen col NICHT NULL ist. Bei LEFT JOIN-Aggregaten ist das die übliche Falle: count(*) würde Kunden ohne Bestellung als „1 Bestellung" zählen (die NULL-Zeile aus dem LEFT JOIN). Lösung: count(o.id).
sum auf leere Gruppe = NULL.
Klingt unintuitiv, ist aber Standard. Eine LEFT JOIN-Zeile, in der die rechte Seite kein Match hat, gibt sum(...) = NULL (nicht 0). In Reports sieht das hässlich aus — COALESCE(sum(total), 0) macht das zu einer Null. Auch count ist hier robuster (gibt 0).
GROUP BY über JOIN-Spalten — Vorsicht.
Bei customers c LEFT JOIN orders o GROUP BY c.id gewinnst du eine Zeile pro Kunde — gut. Aber wenn du Spalten aus orders ungruppiert in SELECT schreibst, wirft Postgres einen Fehler. Lösung: in GROUP BY aufnehmen ODER aggregieren (max, min, array_agg).
Grouping by Position (GROUP BY 1, 2) ist erlaubt.
Postgres akzeptiert numerische Verweise auf SELECT-Spalten in GROUP BY — wie in ORDER BY. Praktisch für Ad-hoc-Queries. In Production-Code besser explizit Spalten-Namen nehmen — die sind robuster gegen Refactoring.
HAVING ohne GROUP BY ist erlaubt.
Wenn keine Gruppierung gewünscht ist, agiert HAVING als globaler Filter über das gesamte Resultset (eine implizite Gruppe). Beispiel: SELECT count(*) FROM orders HAVING count(*) > 0. Selten gebraucht, aber syntaktisch korrekt.
Index für GROUP BY-Spalten lohnt sich.
Bei GROUP BY customer_id auf einer großen orders-Tabelle ohne Index läuft Postgres in einen Hash-Aggregat-Plan, der RAM braucht. Mit Index auf customer_id kann er stattdessen einen Stream-Aggregate-Plan nutzen — schneller und speichersparend. Bei wachsenden Tabellen relevant.