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

FunktionWas sie tutBei NULL
count(*)Anzahl Zeilen (alle)zählt auch NULL-Zeilen
count(col)Anzahl nicht-NULL-WerteNULLs ignoriert
count(DISTINCT col)Anzahl eindeutiger WerteNULLs ignoriert
sum(col)SummeNULLs ignoriert
avg(col)DurchschnittNULLs ignoriert
min(col) / max(col)Minimum / MaximumNULLs ignoriert
array_agg(col)Werte als ArrayNULLs werden ins Array gepackt
string_agg(col, sep)Werte als String mit SeparatorNULLs ignoriert
jsonb_agg(col)Werte als jsonb-ArrayNULLs als JSON-null enthalten
bool_and(col) / every(col)TRUE wenn ALLE TRUENULLs ignoriert
bool_or(col)TRUE wenn IRGENDEINER TRUENULLs ignoriert

Beispiel-Daten

SQL orders mit unterschiedlichen Status
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

SQL
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 |                3
  • count(*) zählt alle Zeilen — egal ob NULLs in den Spalten.
  • count(col) zählt nur Zeilen, in denen col NICHT NULL ist (eine Zeile mit total = NULL zä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

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

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

SQL
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

SQL Pro Kunde alle Bestell-IDs
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:

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

SQL Pro Kunde Status-Liste als String
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, cancelled

Der zweite Parameter ist der Separator. Funktioniert nur auf text-/varchar-Spalten — Zahlen müssen erst gecastet werden:

SQL
SELECT string_agg(id::text, ',' ORDER BY id) FROM orders;
-- 1,2,3,4,5,6

jsonb_agg und jsonb_object_agg

Für JSON-Output direkt aus der DB:

SQL Bestellungen pro Kunde als JSON-Array
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:

SQL
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

SQL Sind alle / mindestens eine Bestellung paid?
SELECT
    customer_id,
    bool_and(status = 'paid') AS all_paid,
    bool_or(status = 'paid')  AS any_paid
FROM orders
GROUP BY customer_id;

Output:

SQL
 customer_id | all_paid | any_paid
-------------+----------+----------
           1 | f        | t
           2 | t        | t
           3 | f        | t

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

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

SQL
 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

/ Weiter

Zurück zu Aggregation & Window Functions

Zur Übersicht