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).

aba AND ba OR bNOT a
TRUETRUETRUETRUEFALSE
TRUEFALSEFALSETRUEFALSE
TRUENULLNULLTRUEFALSE
FALSEFALSEFALSEFALSETRUE
FALSENULLFALSENULLTRUE
NULLNULLNULLNULLNULL

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.

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

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

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

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

AusdruckErgebnis
1 = 1TRUE
1 = NULLNULL
NULL = NULLNULL
1 IS DISTINCT FROM 1FALSE
1 IS DISTINCT FROM NULLTRUE
NULL IS DISTINCT FROM NULLFALSE

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:

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

SQL
SELECT NULLIF(trim(input), '') AS clean_input;

NULL in Aggregat-Funktionen

Aggregate ignorieren NULL-Werte standardmäßig:

SQL
-- 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, max ignorieren NULLs.

Für „NULL als 0 behandeln” hilft COALESCE(x, 0):

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

SQL Mehrere NULLs in UNIQUE-Spalte erlaubt
CREATE TABLE t (email text UNIQUE);
INSERT INTO t VALUES (NULL), (NULL), (NULL);  -- alle drei OK

Wer das nicht will (PG 15+):

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

SQL
SELECT 'Hallo, ' || NULL;            -- NULL
SELECT 'Hallo, ' || COALESCE(name, 'Welt');  -- 'Hallo, Welt' wenn name NULL

concat()-Funktion (im Gegensatz dazu) ignoriert NULL und behandelt es wie leeren String:

SQL
SELECT concat('Hallo, ', NULL, '!');  -- 'Hallo, !'

Praxis-Beispiele

Die NOT IN-Falle in Aktion

SQL Was nicht funktioniert
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:

SQL Was funktioniert
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

SQL Trigger-Funktion mit IS DISTINCT FROM
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

SQL Reporting ohne 'NULL'-Loecher
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 | never

Ohne 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

/ Weiter

Zurück zu SQL-Grundlagen

Zur Übersicht