Aggregat-Funktionen reduzieren mehrere Zeilen auf einen einzelnen Wert pro Gruppe — die klassischen count, sum, avg, plus die mächtigen Sammler array_agg, string_agg und jsonb_agg, die ganze Listen pro Gruppe produzieren. Hier alle wichtigen mit Beispielen, NULL-Verhalten und Pitfalls.
Die wichtigsten Aggregate
| Funktion | Was sie tut | Bei NULL |
|---|---|---|
count(*) | Anzahl Zeilen (alle) | zählt auch NULL-Zeilen |
count(col) | Anzahl nicht-NULL-Werte | NULLs ignoriert |
count(DISTINCT col) | Anzahl eindeutiger Werte | NULLs ignoriert |
sum(col) | Summe | NULLs ignoriert |
avg(col) | Durchschnitt | NULLs ignoriert |
min(col) / max(col) | Minimum / Maximum | NULLs ignoriert |
array_agg(col) | Werte als Array | NULLs werden ins Array gepackt |
string_agg(col, sep) | Werte als String mit Separator | NULLs ignoriert |
jsonb_agg(col) | Werte als jsonb-Array | NULLs als JSON-null enthalten |
bool_and(col) / every(col) | TRUE wenn ALLE TRUE | NULLs ignoriert |
bool_or(col) | TRUE wenn IRGENDEINER TRUE | NULLs ignoriert |
Beispiel-Daten
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
total numeric(10, 2),
status text NOT NULL
);
INSERT INTO orders (customer_id, total, status) VALUES
(1, 99.95, 'paid'),
(1, 49.95, 'paid'),
(1, NULL, 'pending'), -- total NULL: noch nicht berechnet
(2, 199.95, 'paid'),
(3, 9.95, 'paid'),
(3, 29.95, 'cancelled');count — die drei Varianten
myapp=> SELECT
count(*) AS total_rows,
count(total) AS rows_with_total,
count(DISTINCT customer_id) AS unique_customers
FROM orders;
total_rows | rows_with_total | unique_customers
------------+-----------------+------------------
6 | 5 | 3count(*)zählt alle Zeilen — egal ob NULLs in den Spalten.count(col)zählt nur Zeilen, in denencolNICHT NULL ist (eine Zeile mittotal = NULLzählt nicht).count(DISTINCT col)zählt eindeutige Werte (3 verschiedene Kunden, obwohl 6 Bestellungen).
In Reports willst du meistens count(*) für „Anzahl Zeilen" und count(col) nur, wenn NULL als „nicht relevant" interpretiert werden soll.
sum, avg, min, max
myapp=> SELECT
sum(total) AS total_sum,
avg(total) AS avg_value,
min(total) AS smallest,
max(total) AS largest
FROM orders
WHERE status = 'paid';
total_sum | avg_value | smallest | largest
-----------+-----------+----------+---------
359.80 | 89.95 | 9.95 | 199.95Alle vier ignorieren NULL-Werte. sum/avg brauchen numerische Spalten; min/max funktionieren auch auf Text und Datum (alphabetisch / chronologisch).
Tücke: avg auf einer leeren Gruppe (z. B. nach JOIN ohne Treffer) liefert NULL, nicht 0. Wenn du wirklich 0 willst:
SELECT COALESCE(sum(total), 0) AS total_sum FROM orders WHERE 1=0;Auch wichtig: avg rechnet exakt, wenn die Spalte numeric ist — bei integer/bigint rundet's nicht, sondern liefert numeric (Postgres ist hier sauber).
array_agg — Werte als Array sammeln
myapp=> SELECT
customer_id,
array_agg(id ORDER BY created_at) AS order_ids
FROM orders
GROUP BY customer_id;
customer_id | order_ids
-------------+----------------
1 | {1, 2, 3}
2 | {4}
3 | {5, 6}Wichtig: das ORDER BY innerhalb des array_agg(...)-Aufrufs sortiert die Elemente. Ohne diese Klausel ist die Reihenfolge undefiniert.
array_agg(DISTINCT col) deduppliziert:
SELECT array_agg(DISTINCT status ORDER BY status)
FROM orders;
-- {cancelled, paid, pending}string_agg — Werte zu einem String
Sehr nützlich für CSV-artige Reports oder UI-Listen:
myapp=> SELECT
customer_id,
string_agg(status, ', ' ORDER BY id) AS statuses
FROM orders
GROUP BY customer_id;
customer_id | statuses
-------------+----------------------------
1 | paid, paid, pending
2 | paid
3 | paid, cancelledDer zweite Parameter ist der Separator. Funktioniert nur auf text-/varchar-Spalten — Zahlen müssen erst gecastet werden:
SELECT string_agg(id::text, ',' ORDER BY id) FROM orders;
-- 1,2,3,4,5,6jsonb_agg und jsonb_object_agg
Für JSON-Output direkt aus der DB:
myapp=> SELECT
customer_id,
jsonb_agg(
jsonb_build_object(
'id', id,
'total', total,
'status', status
) ORDER BY id
) AS orders
FROM orders
GROUP BY customer_id;
customer_id | orders
-------------+--------------------------------------------------------------
1 | [{"id": 1, "total": 99.95, "status": "paid"},
{"id": 2, "total": 49.95, "status": "paid"},
{"id": 3, "total": null, "status": "pending"}]
2 | [{"id": 4, "total": 199.95, "status": "paid"}]
3 | [{"id": 5, "total": 9.95, "status": "paid"},
{"id": 6, "total": 29.95, "status": "cancelled"}]Sehr praktisch für API-Endpunkte: in einer Query bekommst du ein vollständiges Resultat als JSON, ohne in der Anwendung zusammensetzen zu müssen.
jsonb_object_agg(key, value) baut ein Objekt statt Array:
SELECT jsonb_object_agg(country, customer_count)
FROM (
SELECT country, count(*) AS customer_count
FROM customers
GROUP BY country
) sub;
-- {"AT": 1, "CH": 1, "DE": 2}Boolean-Aggregate
SELECT
customer_id,
bool_and(status = 'paid') AS all_paid,
bool_or(status = 'paid') AS any_paid
FROM orders
GROUP BY customer_id;Output:
customer_id | all_paid | any_paid
-------------+----------+----------
1 | f | t
2 | t | t
3 | f | tbool_and ist TRUE, wenn alle Werte der Gruppe TRUE sind. bool_or ist TRUE, sobald einer TRUE ist. every() ist Synonym zu bool_and.
DISTINCT in Aggregaten
DISTINCT darf in den meisten Aggregaten verwendet werden:
SELECT
count(DISTINCT customer_id) AS unique_customers,
sum(DISTINCT total) AS distinct_amount_sum,
array_agg(DISTINCT status) AS distinct_statuses
FROM orders;Output (mit Beispiel-Daten):
unique_customers | distinct_amount_sum | distinct_statuses
------------------+---------------------+-----------------------
3 | 389.75 | {cancelled,paid,pending}sum(DISTINCT total) summiert nur eindeutige Werte — selten gebraucht, aber nützlich für „wie viele unterschiedliche Beträge insgesamt?".
count(DISTINCT col) ist deutlich teurer als count(col) — Postgres muss intern ein Set aufbauen. Bei großen Tabellen lieber prüfen, ob's wirklich nötig ist.
FAQ
Warum liefert sum auf leerer Gruppe NULL, nicht 0?
SQL-Standard: ein Aggregat ohne Eingabezeilen ist NULL. count(*) ist die Ausnahme — das ist immer ein Integer. Für „NULL als 0" in Reports: COALESCE(sum(...), 0).
count(*) oder count(1) — gibt's Unterschiede?
Funktional identisch. count(*) ist Standard-SQL und liest sich klarer. Manche legacy-Code-Bases nutzen count(1) (aus historischen Performance-Mythen anderer DBs) — in Postgres ist es exakt gleich schnell. Empfehlung: count(*).
array_agg nimmt NULLs MIT auf.
Anders als sum/avg ignoriert array_agg NULL-Werte nicht — sie landen als NULL-Element im Array. Wenn das nicht gewünscht ist: array_agg(col) FILTER (WHERE col IS NOT NULL) oder array_remove(array_agg(col), NULL).
ORDER BY innerhalb des Aggregats ist wichtig.
Bei array_agg, string_agg, jsonb_agg: ohne ORDER BY ist die Reihenfolge der Elemente nicht garantiert. Postgres entscheidet das nach Plan-Eigenschaften — kann sich zwischen Versionen oder bei kleinen Daten-Änderungen ändern. Wer auf Reihenfolge angewiesen ist (oft!), schreibt's explizit hin.
FILTER (WHERE …) ist eleganter als CASE WHEN.
Statt count(CASE WHEN status='paid' THEN 1 END) schreibst du count(*) FILTER (WHERE status='paid'). Klarer in der Absicht und etwas schneller. Eigener Artikel: FILTER-Klausel.
Eigene Aggregate sind möglich.
CREATE AGGREGATE erlaubt eigene Aggregat-Funktionen — z. B. einen geometrischen Durchschnitt oder eine Median-Funktion. In der Praxis selten gebraucht, aber das Feature existiert. Built-ins decken 99% der Fälle ab.
Aggregate auf jsonb für API-Output sind ein Postgres-USP.
jsonb_agg(...) plus jsonb_build_object(...) baut komplexe verschachtelte JSON-Strukturen direkt in der DB — perfekt für REST-Endpunkte, die JSON liefern. Andere DBs brauchen oft mehrere Round-Trips oder Anwendungs-Logik.
Weiterführende Ressourcen
Externe Quellen
- Aggregate Functions – PostgreSQL Documentation
- JSON Functions and Operators – jsonb_agg
- Array Functions – array_agg
- String Functions – string_agg