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

SQL customers + orders
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:

SQL
SELECT
    customer_id,
    count(*)        AS order_count,
    sum(total)      AS total_spent
FROM orders
GROUP BY customer_id
ORDER BY total_spent DESC;

Output:

SQL
 customer_id | order_count | total_spent
-------------+-------------+-------------
           2 |           1 |      199.95
           1 |           3 |      169.85
           4 |           1 |       79.95
           3 |           2 |       39.90

GROUP 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:

SQL
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 function

Postgres 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

SQL Pro Kunde und Status
SELECT
    customer_id,
    status,
    count(*)   AS count,
    sum(total) AS total
FROM orders
GROUP BY customer_id, status
ORDER BY customer_id, status;

Output:

SQL
 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.95

Eine 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.

SQL Pro Kunde inkl. Name
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:

SQL
 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.90

Drei wichtige Details:

  • LEFT JOIN statt INNER JOIN — Kunden ohne Bestellung sollen mit count = 0 auftauchen.
  • count(o.id) statt count(*)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)sum gibt für eine Gruppe ohne Werte NULL zurück, nicht 0. COALESCE macht's zu 0 für Reports.

HAVING — Gruppen filtern

WHERE filtert vor der Gruppierung — auf einzelnen Zeilen. HAVING filtert nach der Gruppierung — auf den fertigen Gruppen.

SQL Nur Kunden mit mehr als 100 Euro Umsatz
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:

SQL
 customer_id | order_count | total_spent
-------------+-------------+-------------
           2 |           1 |      199.95
           1 |           3 |      169.85

HAVING sum(total) > 100 filtert die Gruppen heraus, deren Summe ≤ 100 ist.

WHERE oder HAVING?

Beide filtern, aber an unterschiedlichen Punkten:

KlauselWirktBeispiel
WHEREauf einzelne Zeilen vor GROUP BYWHERE status = 'paid'
HAVINGauf fertige GruppenHAVING sum(total) > 100

Oft kombiniert man beide:

SQL WHERE filtert Zeilen, HAVING filtert Gruppen
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:

  1. FROM — Quelltabellen
  2. WHERE — Zeilen filtern
  3. GROUP BY — Gruppieren
  4. HAVING — Gruppen filtern
  5. SELECT — Spalten ausgeben (inkl. Aggregaten)
  6. ORDER BY — sortieren
  7. LIMIT — 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:

SQL Postgres-Erweiterung: Alias in GROUP BY
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:

SQL Aliase in HAVING gehen NICHT
-- 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

SQL
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)

SQL
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

SQL
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.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Aggregation & Window Functions

Zur Übersicht