Wenn man PostgreSQL eine Frage stellt, sieht das in 90 % der Fälle so aus: SELECT … FROM … WHERE …. Hier behandeln wir die Grundform: welche Spalten man wählt, wie man Zeilen filtert und welche Operatoren in WHERE-Bedingungen erlaubt sind. Window Functions, Aggregate und Joins kommen in eigenen Kapiteln.
Grundform
Stell dir eine Tabelle users vor mit Spalten id, email, name, created_at. Wir wollen die 50 zuletzt registrierten Nutzer dieses Jahres lesen, sortiert von neu nach alt:
SELECT id, email, created_at
FROM users
WHERE created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 50;Was die einzelnen Klauseln tun:
SELECT id, email, created_at— welche Spalten sollen im Ergebnis stehenFROM users— aus welcher Tabelle kommen sieWHERE created_at >= '2026-01-01'— welche Zeilen werden eingeschlossenORDER BY created_at DESC— in welcher Reihenfolge (DESC = absteigend, neueste zuerst)LIMIT 50— wie viele Zeilen maximal
Die Klauseln werden nicht in der Reihenfolge ausgewertet, in der sie geschrieben werden. Logisch ausgewertet wird:
FROM— Quelle bestimmenWHERE— Zeilen filternGROUP BY(falls vorhanden) — Gruppen bildenHAVING— Gruppen filternSELECT— Spalten projizierenORDER BY— sortierenLIMIT/OFFSET— kürzen
Wichtig zu wissen: Spalten-Aliasse (AS x), die in SELECT definiert werden, sind in WHERE nicht verfügbar — WHERE läuft logisch vor SELECT. In ORDER BY schon.
Spalten und Aliasse
Mit AS kann man Spalten im Ergebnis umbenennen oder berechnete Werte beschriften:
SELECT
id,
email AS contact, -- Spalte umbenennen
created_at AS registered_at, -- Spalte umbenennen
(price * quantity) AS total -- berechnete Spalte mit Namen
FROM orders;Beispiel-Output:
id | contact | registered_at | total
----+------------------+------------------------+--------
1 | alice@example.de | 2026-04-12 10:23:14+00 | 19.90
2 | bob@example.de | 2026-04-12 11:45:00+00 | 59.70AS selbst ist syntaktisch optional — email contact würde auch gehen. Mit AS wird’s lesbarer und sicherer: ohne AS ergibt ein versehentlich fehlendes Komma einen versteckten Alias statt eines Fehlers (z. B. SELECT id email FROM users benennt die id-Spalte stillschweigend in email um — niemand wirft einen Fehler).
SELECT * listet alle Spalten der Tabelle. Praktisch in der Erkundung, in Produktion meiden — sobald die Tabelle eine neue Spalte bekommt, ändert sich das Resultset und Anwendungen brechen lautlos.
WHERE-Operatoren
| Operator | Beispiel | Bedeutung |
|---|---|---|
= <> != | status = 'paid' | Gleichheit / Ungleichheit |
< <= > >= | total > 100 | Vergleiche |
BETWEEN … AND … | total BETWEEN 100 AND 200 | Inklusiv beide Grenzen |
IN (…) | status IN ('paid','shipped') | Wert in Liste |
NOT IN (…) | id NOT IN (1,2,3) | Wert nicht in Liste |
IS NULL IS NOT NULL | deleted_at IS NULL | NULL-Prüfung (siehe NULL-Artikel) |
LIKE | email LIKE '%@example.com' | Pattern-Match (case-sensitive) |
ILIKE | name ILIKE 'müller%' | Pattern-Match (case-insensitive, Postgres-Erweiterung) |
~ ~* | email ~ '^[a-z]+@' | Regex-Match (case-sensitive / case-insensitive) |
EXISTS (…) | EXISTS (SELECT 1 FROM …) | Subquery liefert mind. eine Zeile |
AND OR NOT | a AND b | Boolesche Verknüpfung |
LIKE und ILIKE — Pattern-Matching
Mit LIKE kann man Strings nach Mustern durchsuchen. Zwei Wildcards stehen zur Verfügung: % matcht beliebig viele Zeichen (auch null), _ genau ein Zeichen.
-- Alle E-Mails der Domain example.com:
-- Trifft auf 'alice@example.com', 'bob@example.com', ...
SELECT * FROM users WHERE email LIKE '%@example.com';
-- E-Mails mit genau 5 Zeichen vor dem @:
-- Trifft auf 'alice@...', NICHT auf 'bob@...' oder 'charles@...'
SELECT * FROM users WHERE email LIKE '_____@%';
-- Suche nach 'mueller' im Namen, egal ob klein oder grossgeschrieben:
-- Trifft auf 'Mueller', 'MUELLER', 'müller-Schmidt', ...
SELECT * FROM users WHERE name ILIKE '%mueller%';ILIKE ist die case-insensitive Variante (Postgres-spezifisch — kein SQL-Standard).
Wer % oder _ als wörtliches Zeichen sucht, escapen mit \ (Default) oder eigenem Escape-Char:
SELECT * FROM logs WHERE message LIKE '50\%%' ESCAPE '\';Performance-Hinweis: LIKE 'foo%' (Prefix) kann einen Index nutzen, wenn die Spalte mit text_pattern_ops indiziert ist. LIKE '%foo' (Suffix) und LIKE '%foo%' (Substring) brauchen für schnelles Finden den pg_trgm-Index — siehe Kapitel Performance/Indexes.
Verknüpfung mit AND, OR, NOT
SELECT * FROM orders
WHERE (status = 'paid' OR status = 'shipped')
AND total > 100
AND NOT customer_id = 42;Klammern sind nicht optional, wenn AND und OR gemischt werden — Postgres bewertet AND strenger als OR, und die Logik ohne Klammern ist oft anders als gemeint.
CASE — bedingte Spaltenwerte
SELECT
id,
CASE
WHEN total >= 1000 THEN 'gold'
WHEN total >= 100 THEN 'silver'
ELSE 'bronze'
END AS tier
FROM orders;Kurzform für Gleichheits-Vergleiche:
SELECT
id,
CASE status
WHEN 'paid' THEN 'OK'
WHEN 'pending' THEN 'wartend'
ELSE 'sonstiges'
END AS status_de
FROM orders;CASE funktioniert in SELECT, WHERE, ORDER BY — überall, wo ein Ausdruck stehen darf.
DISTINCT und DISTINCT ON
-- Eindeutige Werte
SELECT DISTINCT country FROM users;
-- Eindeutige Kombinationen
SELECT DISTINCT country, language FROM users;Postgres-Erweiterung: DISTINCT ON (col) — pro col-Wert eine Zeile, und zwar die erste laut ORDER BY:
SELECT DISTINCT ON (customer_id)
customer_id, id, created_at, total
FROM orders
ORDER BY customer_id, created_at DESC;ORDER BY muss mit der DISTINCT ON-Spalte beginnen. Ein Klassiker für „neuestes pro Gruppe”-Queries.
Praxis-Beispiele
Aktive Bestellungen der letzten 30 Tage
SELECT
o.id,
o.total,
o.status,
o.created_at,
CASE
WHEN o.total >= 500 THEN 'large'
WHEN o.total >= 100 THEN 'medium'
ELSE 'small'
END AS size_bucket
FROM orders o
WHERE o.status IN ('paid', 'shipped', 'delivered')
AND o.created_at >= now() - interval '30 days'
AND o.deleted_at IS NULL
ORDER BY o.created_at DESC
LIMIT 100;Vier typische Bausteine in einer Query: Filter über IN, Zeitfenster über interval, Soft-Delete-Schutz mit IS NULL, und ein berechneter Bucket über CASE.
Suche über E-Mail-Domain
myapp=> SELECT id, email, name FROM users
WHERE email ILIKE '%@example.com'
AND deleted_at IS NULL;
id | email | name
----+----------------------+--------
1 | alice@example.com | Alice
7 | bob.smith@Example.com| Bob
12 | carol@example.com | CarolILIKE macht’s case-insensitive — Example.com wird mitgefunden.
Existenz-Check mit EXISTS statt JOIN
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
AND o.status = 'paid'
)
ORDER BY c.name;EXISTS ist hier sauberer als ein JOIN … GROUP BY — wir brauchen die Bestellungen nicht im Resultat, nur die Existenz. Postgres optimiert das oft sehr effizient mit einer „Semi-Join”-Strategie.
Ranking mit Tier und Filter
SELECT
id,
total,
CASE
WHEN total >= 1000 THEN 'gold'
WHEN total >= 100 THEN 'silver'
ELSE 'bronze'
END AS tier
FROM orders
WHERE total >= 100 -- nicht via tier-Alias!
ORDER BY total DESC;Achtung: das Filter WHERE tier = 'silver' würde nicht funktionieren, weil das tier-Alias in WHERE nicht sichtbar ist (logische Ausführungsreihenfolge). Lösung: Filter auf der Roh-Spalte total, oder Subquery/CTE.
Interessantes
SELECT-Aliasse sind in WHERE NICHT nutzbar.
SELECT (a+b) AS sum FROM t WHERE sum > 10 schlägt mit „column ‘sum’ does not exist” fehl. WHERE läuft logisch vor SELECT. Lösung: den Ausdruck wiederholen — oder eine Subquery / CTE verwenden, in der das Alias in der äußeren Ebene sichtbar ist.
ILIKE ist Postgres-spezifisch.
Der SQL-Standard kennt nur LIKE (case-sensitive). ILIKE ist eine Postgres-Erweiterung — praktisch, aber nicht portabel zu MySQL/Oracle. Wer plattformübergreifend bleiben will: LOWER(spalte) LIKE LOWER('%pattern%') (kostet Performance, weil kein Index ohne functional index).
DISTINCT ON ist auch Postgres-spezifisch — und sehr maechtig.
In Standard-SQL müsste man dasselbe mit ROW_NUMBER() OVER (…) und einer Subquery bauen — drei Zeilen statt einer. DISTINCT ON ist ein versteckter Postgres-Trumpf, den auch erfahrene Devs aus anderen DBs oft nicht kennen.
* in Production-Queries vermeiden.
Schemas wachsen, neue Spalten kommen dazu — SELECT * reicht das alles ungefragt an die Anwendung weiter. Resultat: Memory-Mehrbedarf, breitere Wire-Frames, manchmal unerwünschte Spalten in Logs. Explizite Spalten-Listen sind langfristig stabiler.
LIKE '%foo%' ohne Index ist linear.
Wer Volltextsuche in einer großen Tabelle bauen will, sollte nicht bei ILIKE '%suchterm%' bleiben. Postgres hat dafür pg_trgm (Trigram-Index für Fuzzy-Match) und tsvector/tsquery (Full-Text-Search). Beide bringen Sub-Sekunden-Performance auf Millionen-Zeilen.
Weiterführende Ressourcen
Externe Quellen
- SELECT – PostgreSQL Documentation
- Pattern Matching – PostgreSQL Documentation
- Conditional Expressions (CASE)
- DISTINCT ON – PostgreSQL Documentation