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:
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
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):
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.95Das 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.:
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.
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:
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:
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:
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.