Die FILTER-Klausel ist eine elegante Möglichkeit, Aggregate bedingt auszuführen. Statt mit CASE WHEN ... THEN ... END herumzubauen, schreibst du direkt count(*) FILTER (WHERE status = 'paid'). SQL-Standard seit 2003, in Postgres seit 9.4. Im Reporting-Alltag fast unverzichtbar.

Das Problem ohne FILTER

Klassische Aufgabe: in einer Query mehrere bedingte Counts/Summen pro Gruppe ausgeben — paid-Bestellungen, pending-Bestellungen, cancelled-Bestellungen, Gesamt:

SQL Alte CASE-WHEN-Variante
SELECT
    customer_id,
    count(*)                                                    AS total,
    count(CASE WHEN status = 'paid'      THEN 1 END)            AS paid,
    count(CASE WHEN status = 'pending'   THEN 1 END)            AS pending,
    count(CASE WHEN status = 'cancelled' THEN 1 END)            AS cancelled,
    sum(CASE WHEN status = 'paid' THEN total ELSE 0 END)        AS paid_total
FROM orders
GROUP BY customer_id;

Funktioniert. Aber die CASE WHEN ... THEN 1 END-Konstrukte sind verbose und tricky — END ohne ELSE liefert NULL, was count dann ignoriert. Falls man das einmal vergisst (z. B. THEN 1 ELSE 0), zählt man alle Zeilen statt nur die gefilterten.

Mit FILTER

SQL Saubere Variante
SELECT
    customer_id,
    count(*)                                       AS total,
    count(*) FILTER (WHERE status = 'paid')        AS paid,
    count(*) FILTER (WHERE status = 'pending')     AS pending,
    count(*) FILTER (WHERE status = 'cancelled')   AS cancelled,
    sum(total) FILTER (WHERE status = 'paid')      AS paid_total
FROM orders
GROUP BY customer_id;

Output (mit Beispiel-Daten):

SQL
 customer_id | total | paid | pending | cancelled | paid_total
-------------+-------+------+---------+-----------+------------
           1 |     3 |    2 |       1 |         0 |     149.90
           2 |     1 |    1 |       0 |         0 |     199.95
           3 |     2 |    1 |       0 |         1 |       9.95

Das liest sich wie der Pseudocode: „zähle Zeilen, wo status = 'paid'". Klare Absicht, weniger Tippfehler-Risiko.

Mit jedem Aggregat kombinierbar

FILTER funktioniert mit allen Aggregat-Funktionen — count, sum, avg, array_agg, string_agg, jsonb_agg, etc.:

SQL Pro Land verschiedene Aggregate
SELECT
    country,
    count(*)                                          AS customers,
    count(*) FILTER (WHERE active)                    AS active_customers,
    avg(total) FILTER (WHERE status = 'paid')         AS avg_paid_order,
    array_agg(name ORDER BY name)
        FILTER (WHERE active)                         AS active_names,
    string_agg(email, ', ' ORDER BY email)
        FILTER (WHERE newsletter_opt_in)              AS newsletter_emails
FROM customers
GROUP BY country;

Mehrere FILTER-Klauseln in einer Query — jede mit eigener Bedingung. Sehr nützlich für Reports, in denen pro Gruppe verschiedene Bedingte Werte ausgegeben werden sollen.

Pivot-artige Reports

Häufiger Use-Case: eine „Spalte pro Wert"-Tabelle aus einer „Zeile pro Wert"-Tabelle bauen.

SQL Tagesübersicht: Bestellungen pro Status
SELECT
    date_trunc('day', created_at)::date           AS day,
    count(*) FILTER (WHERE status = 'pending')    AS pending,
    count(*) FILTER (WHERE status = 'paid')       AS paid,
    count(*) FILTER (WHERE status = 'shipped')    AS shipped,
    count(*) FILTER (WHERE status = 'cancelled')  AS cancelled,
    count(*)                                      AS total
FROM orders
WHERE created_at >= now() - interval '30 days'
GROUP BY day
ORDER BY day;

Eine Zeile pro Tag, Status als Spalten. Mit normalen Joins/Subqueries wäre das deutlich umständlicher — FILTER macht's lesbar.

Ratio-Berechnungen

Für Verhältnisse (Conversion-Rate, Cancel-Rate etc.) lässt sich FILTER mit Division kombinieren:

SQL Cancel-Rate pro Kunde
SELECT
    customer_id,
    count(*)                                              AS total_orders,
    count(*) FILTER (WHERE status = 'cancelled')          AS cancelled,
    ROUND(
        100.0 * count(*) FILTER (WHERE status = 'cancelled')
              / NULLIF(count(*), 0),
        2
    )                                                     AS cancel_rate_pct
FROM orders
GROUP BY customer_id
HAVING count(*) > 0
ORDER BY cancel_rate_pct DESC;

NULLIF(count(*), 0) schützt vor Division durch Null (würde sonst einen Fehler werfen, falls die Gruppe leer wäre).

FILTER auch in Window-Functions

Ab PG 9.4 funktioniert FILTER auch in Aggregaten, die als Window-Function genutzt werden:

SQL Laufende Summe nur paid-Bestellungen
SELECT
    id,
    customer_id,
    status,
    total,
    sum(total) FILTER (WHERE status = 'paid')
        OVER (PARTITION BY customer_id ORDER BY created_at) AS running_paid
FROM orders;

Damit kannst du laufende Summen bilden, die nur bestimmte Zeilen einbeziehen. Mehr zu Window-Functions im Artikel Window-Functions Überblick.

WHERE + FILTER kombinieren

WHERE filtert vor der Gruppierung, FILTER filtert pro Aggregat. Beide haben ihre Rolle:

SQL
SELECT
    customer_id,
    count(*)                                       AS recent_orders,
    count(*) FILTER (WHERE total > 100)            AS recent_big
FROM orders
WHERE created_at >= now() - interval '30 days'    -- nur recent
GROUP BY customer_id;

Hier filtert WHERE die Zeilen auf den Zeitraum (alles davor wird vergessen), und FILTER zählt innerhalb der recent-Bestellungen die mit total > 100. Klare Trennung der Verantwortlichkeiten.

Besonderheiten

FILTER ist SQL-Standard, kein Postgres-Trick.

SQL:2003 hat das eingeführt. Postgres unterstützt es seit 9.4 (2014). Andere moderne DBs (MariaDB 10.6+, Snowflake, BigQuery) ebenso. In MySQL fehlt's noch — dort bleibt nur die CASE WHEN-Variante.

Performance: meist gleichschnell wie CASE WHEN.

Postgres optimiert FILTER und CASE WHEN ähnlich — der Plan ist oft identisch. Lesbarkeit ist daher das Hauptargument für FILTER, nicht Geschwindigkeit. In komplexen Aggregaten kann FILTER aber den Optimizer-Weg vereinfachen.

Kombiniert mit DISTINCT in Aggregaten.

count(DISTINCT customer_id) FILTER (WHERE status = 'paid') zählt eindeutige Kunden mit bezahlter Bestellung. Funktioniert wie erwartet — DISTINCT und FILTER schließen sich nicht aus.

FILTER ist nicht das gleiche wie HAVING.

HAVING filtert ganze Gruppen am Ende. FILTER filtert pro Aggregat-Aufruf, innerhalb einer Gruppe. Ein FILTER kann also für eine Gruppe count = 0 ergeben, ohne dass die Gruppe rausfliegt — sie taucht mit anderen Aggregat-Werten auf. HAVING würde sie ganz entfernen.

Bei vielen Status-Werten lieber dynamisch.

Wenn die Liste der Stati groß und änderungsanfällig ist, lohnt sich ein dynamischer Pivot — z. B. mit der crosstab-Funktion aus der tablefunc-Extension. Für ~5-10 fixe Werte ist FILTER aber unschlagbar in Lesbarkeit.

FILTER auf jsonb_agg ergibt elegante APIs.

jsonb_agg(row_to_json(o)) FILTER (WHERE status = 'paid') baut ein JSON-Array nur aus paid-Zeilen. Mit jsonb_object_agg und FILTER lassen sich auch verschachtelte API-Outputs strukturiert direkt aus der DB liefern.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Aggregation & Window Functions

Zur Übersicht