NULL in SQL bedeutet nicht „leer” oder „0” — es bedeutet „Wert unbekannt”. Daraus folgt eine ganze Reihe nicht-intuitiver Konsequenzen: NULL = NULL ist nicht TRUE, NULL OR TRUE ergibt TRUE, aber NULL AND TRUE ergibt NULL. Wer das einmal verinnerlicht hat, vermeidet die meisten SQL-Bugs.
Three-Valued Logic
In SQL existieren drei Wahrheitswerte: TRUE, FALSE, UNKNOWN (= NULL).
a | b | a AND b | a OR b | NOT a |
|---|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE | FALSE |
| TRUE | FALSE | FALSE | TRUE | FALSE |
| TRUE | NULL | NULL | TRUE | FALSE |
| FALSE | FALSE | FALSE | FALSE | TRUE |
| FALSE | NULL | FALSE | NULL | TRUE |
| NULL | NULL | NULL | NULL | NULL |
Faustregel: Sobald eine Bedingung NULL ist und der Boolesche Operator nicht eindeutig entschieden ist, wird das Ergebnis ebenfalls NULL. NULL AND FALSE ist FALSE (eindeutig), NULL OR TRUE ist TRUE (eindeutig), aber NULL AND TRUE ist NULL.
NULL in WHERE-Bedingungen
Folge der Three-Valued Logic: eine Zeile passiert die WHERE-Bedingung nur, wenn das Ergebnis TRUE ist — NULL zählt als „nicht erfüllt” (genauso wie FALSE).
Stell dir eine Tabelle t mit einer Spalte x vor, in der drei Zeilen stehen: x = 1, x = 2, x = NULL.
-- Welche Zeilen haben x = 1?
SELECT * FROM t WHERE x = 1;
-- Liefert: nur die Zeile mit x=1.
-- (Bei x=NULL ergibt 'NULL = 1' eben NULL, also nicht erfüllt.)
-- Welche Zeilen haben x ungleich 1?
SELECT * FROM t WHERE x <> 1;
-- Liefert: NUR die Zeile mit x=2.
-- Die NULL-Zeile fehlt — denn 'NULL <> 1' ist NULL, nicht TRUE!Das ist intuitiv überraschend: man würde erwarten, dass „nicht 1” die NULL-Zeile mit einschließt. Tut es aber nicht. Wer wirklich „alles, was nicht 1 ist (inkl. NULL)” haben will, muss das explizit ergänzen:
SELECT * FROM t WHERE x <> 1 OR x IS NULL;
-- Liefert jetzt beide: x=2 und x=NULL.IS NULL — der einzig richtige Vergleich
= NULL funktioniert nicht wie erwartet:
SELECT * FROM users WHERE deleted_at = NULL; -- Liefert NIE Zeilen
SELECT * FROM users WHERE deleted_at IS NULL; -- Korrekt= NULL ergibt immer NULL (also „nicht erfüllt”). IS NULL ist der einzige Operator, der TRUE zurückgibt, wenn der Wert tatsächlich NULL ist.
Die Postgres-Doku rät: nutzt IS NULL und IS NOT NULL ausschließlich. = NULL ist (meist) syntaktisch zwar erlaubt, semantisch aber selten gemeint.
IS DISTINCT FROM — NULL-sicheres Vergleichen
Manchmal will man wirklich zwei Werte vergleichen, wo NULL als „gleich” zu einem anderen NULL gilt — z. B. „hat sich der Wert geändert?”:
UPDATE users SET name = 'Alice'
WHERE id = 1
AND name IS DISTINCT FROM 'Alice';IS DISTINCT FROM behandelt zwei NULLs als gleich (also nicht „distinct”) — das ist der Vergleich, den man normalerweise von = erwartet.
Inverse Form: IS NOT DISTINCT FROM.
| Ausdruck | Ergebnis |
|---|---|
1 = 1 | TRUE |
1 = NULL | NULL |
NULL = NULL | NULL |
1 IS DISTINCT FROM 1 | FALSE |
1 IS DISTINCT FROM NULL | TRUE |
NULL IS DISTINCT FROM NULL | FALSE |
Sehr nützlich in UPDATE … WHERE …-Patterns, in denen man No-Op-Updates vermeiden will, oder in Trigger-Logik, die nur bei echten Änderungen reagieren soll.
COALESCE und NULLIF
COALESCE(a, b, c, …) gibt das erste Argument zurück, das nicht NULL ist:
SELECT COALESCE(nickname, name, email) AS display_name
FROM users;Dem Prinzip „Default-Wert” entsprechend: Anzeige-Name ist Spitzname, sonst Name, sonst E-Mail.
NULLIF(a, b) ist die Umkehrung: gibt NULL zurück, wenn a = b, sonst a. Wird häufig genutzt, um „leere Strings” als NULL zu behandeln:
SELECT NULLIF(trim(input), '') AS clean_input;NULL in Aggregat-Funktionen
Aggregate ignorieren NULL-Werte standardmäßig:
-- Werte: 10, 20, NULL, 30
SELECT
count(*), -- 4 — alle Zeilen
count(x), -- 3 — nur nicht-NULL
sum(x), -- 60
avg(x); -- 20 (60/3, nicht 60/4!)Wichtig:
count(*)zählt alle Zeilen (egal ob NULL).count(spalte)zählt nur nicht-NULL-Werte dieser Spalte.sum,avg,min,maxignorieren NULLs.
Für „NULL als 0 behandeln” hilft COALESCE(x, 0):
SELECT avg(COALESCE(x, 0)) FROM t; -- 15 (60/4)NULL in UNIQUE und Foreign Keys
UNIQUE-Constraints behandeln NULLs nicht als gleich (Default in SQL/PostgreSQL):
CREATE TABLE t (email text UNIQUE);
INSERT INTO t VALUES (NULL), (NULL), (NULL); -- alle drei OKWer das nicht will (PG 15+):
CREATE TABLE t (
email text UNIQUE NULLS NOT DISTINCT
);Mit NULLS NOT DISTINCT werden zwei NULLs als gleich behandelt — der zweite Insert schlägt fehl.
NULL und String-Konkatenation
Der ||-Operator liefert NULL, wenn ein Operand NULL ist:
SELECT 'Hallo, ' || NULL; -- NULL
SELECT 'Hallo, ' || COALESCE(name, 'Welt'); -- 'Hallo, Welt' wenn name NULLconcat()-Funktion (im Gegensatz dazu) ignoriert NULL und behandelt es wie leeren String:
SELECT concat('Hallo, ', NULL, '!'); -- 'Hallo, !'Praxis-Beispiele
Die NOT IN-Falle in Aktion
myapp=> SELECT id FROM customers
WHERE id NOT IN (
SELECT customer_id FROM orders WHERE status = 'paid'
);
id
----
(0 rows)Das Resultset ist leer — selbst wenn es Kunden ohne bezahlte Bestellungen gibt. Grund: in der Subquery existiert mindestens eine Zeile mit customer_id IS NULL (z. B. eine Bestellung ohne zugewiesenen Kunden). NOT IN kombiniert mit NULL liefert für jede Zeile NULL, also kein Match.
Saubere Lösung mit NOT EXISTS:
SELECT c.id
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
AND o.status = 'paid'
);NOT EXISTS mit explizitem Join ist NULL-sicher. Generell die robustere Form für „existiert nicht”-Checks.
Audit-Trigger nur bei echter Änderung feuern
CREATE OR REPLACE FUNCTION log_user_change()
RETURNS trigger AS $$
BEGIN
-- Nur loggen, wenn sich die E-Mail oder der Name wirklich aendert
IF OLD.email IS DISTINCT FROM NEW.email
OR OLD.name IS DISTINCT FROM NEW.name THEN
INSERT INTO user_audit (user_id, old_email, new_email, changed_at)
VALUES (NEW.id, OLD.email, NEW.email, now());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;IS DISTINCT FROM behandelt zwei NULLs als gleich — das ist genau das gewünschte Verhalten für „hat sich der Wert geändert?”. Mit <> würde der Trigger NULL-Übergänge missen (NULL → ‘foo’ wäre NULL <> 'foo' = NULL, also nicht erfüllt).
Aggregation mit COALESCE für sichere Defaults
myapp=> SELECT
customer_id,
COALESCE(sum(total), 0) AS total_paid,
COALESCE(count(*), 0) AS order_count,
COALESCE(max(created_at)::text,
'never') AS last_order
FROM orders
WHERE status = 'paid'
GROUP BY customer_id;
customer_id | total_paid | order_count | last_order
-------------+------------+-------------+------------------------
1 | 299.85 | 3 | 2026-05-01 12:00:00+00
2 | 0.00 | 0 | neverOhne COALESCE würden die Spalten für Kunden ohne bezahlte Bestellungen NULL sein — unschön im Reporting-Output. Die 0/'never'-Defaults machen das Ergebnis selbsterklärend.
Häufige Stolperfallen
= NULL ist immer NULL — daher kein Match.
Klassischer Fehler beim Übersetzen aus Mainstream-Sprachen, in denen == null die normale Vergleichsoperation ist. SQL kennt nur IS NULL. Sobald du = NULL siehst, ist’s wahrscheinlich falsch.
WHERE x <> 1 schliesst NULL-Zeilen aus.
„Nicht 1” sollte intuitiv auch NULL einschliessen — tut es nicht. Wer wirklich „alle Zeilen ausser jenen mit x=1” will, schreibt WHERE x <> 1 OR x IS NULL.
NOT IN mit NULL in der Liste — Killer-Bug.
WHERE x NOT IN (1, 2, NULL) ergibt immer NULL für jeden x — also keine Treffer. Grund: x NOT IN (…) ist äquivalent zu x <> 1 AND x <> 2 AND x <> NULL, und letzteres ist NULL. Lösung: NOT EXISTS mit Subquery, oder die NULL aus der Liste filtern.
Aggregate ohne Zeilen liefern NULL, nicht 0.
SELECT sum(x) FROM t WHERE … liefert NULL, wenn keine Zeile passt — nicht 0. Wer 0 will: COALESCE(sum(x), 0). Häufiger Bug in Reporting-Queries, die dann „N/A” statt 0 anzeigen.
UNIQUE lässt mehrere NULLs durch — bis PG 14.
Bis PostgreSQL 14 war das Verhalten unverhandelbar: NULLs gelten als „distinct”, also dürfen mehrere davon in einer UNIQUE-Spalte stehen. Ab PG 15 lässt sich das mit NULLS NOT DISTINCT umkehren — vorher war’s eine ständige Quelle von „warum ist mein UNIQUE wirkungslos?”-Fragen.
JOIN auf NULL-Spalten matcht nicht.
LEFT JOIN t2 ON t1.x = t2.x matcht keine NULL-Zeilen — NULL = NULL ist ja nicht TRUE. Wer das will: ON t1.x IS NOT DISTINCT FROM t2.x. Selten gebraucht, aber bei Self-Joins über optionale FKs ein Wegweiser.
Weiterführende Ressourcen
Externe Quellen
- Comparison Operators – PostgreSQL Documentation
- Conditional Expressions (COALESCE, NULLIF)
- Aggregate Functions
- Release Notes 15: NULLS NOT DISTINCT