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:

SQL Minimal-Query
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 stehen
  • FROM users — aus welcher Tabelle kommen sie
  • WHERE created_at >= '2026-01-01' — welche Zeilen werden eingeschlossen
  • ORDER 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:

  1. FROM — Quelle bestimmen
  2. WHERE — Zeilen filtern
  3. GROUP BY (falls vorhanden) — Gruppen bilden
  4. HAVING — Gruppen filtern
  5. SELECT — Spalten projizieren
  6. ORDER BY — sortieren
  7. LIMIT / 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:

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

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

AS 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

OperatorBeispielBedeutung
= <> !=status = 'paid'Gleichheit / Ungleichheit
< <= > >=total > 100Vergleiche
BETWEEN … AND …total BETWEEN 100 AND 200Inklusiv 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 NULLdeleted_at IS NULLNULL-Prüfung (siehe NULL-Artikel)
LIKEemail LIKE '%@example.com'Pattern-Match (case-sensitive)
ILIKEname 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 NOTa AND bBoolesche 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.

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

SQL
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

SQL
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

SQL Status-Label berechnen
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:

SQL
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

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

SQL Pro Kunde die juengste Bestellung
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

SQL
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

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

ILIKE macht’s case-insensitive — Example.com wird mitgefunden.

Existenz-Check mit EXISTS statt JOIN

SQL Kunden mit mindestens einer bezahlten Bestellung
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

SQL Bestellungen mit berechneten Spalten und Filterung darauf
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

/ Weiter

Zurück zu SQL-Grundlagen

Zur Übersicht