NOT NULL markiert eine Spalte als Pflichtfeld, DEFAULT liefert einen Wert wenn beim INSERT nichts angegeben wird. Beide zusammen sind die Basis-Werkzeuge für sauberes Schema-Design — und beide haben Eigenheiten beim nachträglichen Hinzufügen, gerade auf großen Tabellen.

Die Grundform

SQL
CREATE TABLE orders (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL,
    total       numeric(10,2) NOT NULL,
    status      text   NOT NULL DEFAULT 'pending',
    created_at  timestamptz NOT NULL DEFAULT now(),
    metadata    jsonb  NOT NULL DEFAULT '{}'::jsonb
);

Drei häufige Default-Patterns sichtbar:

  • Konstanter String ('pending')
  • Funktions-Aufruf (now())
  • Typed Literal ('{}'::jsonb)

NOT NULL ist eine eigene Constraint — kein DEFAULT macht sie überflüssig. INSERT ohne Wert für customer_id schlägt fehl.

Default-Wert-Quellen

Defaults können beliebige Ausdrücke sein — solange sie zur Schreib-Zeit auswertbar sind:

SQL
CREATE TABLE events (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    created_at   timestamptz NOT NULL DEFAULT now(),
    event_uuid   uuid        NOT NULL DEFAULT gen_random_uuid(),
    session_id   uuid        NOT NULL DEFAULT uuidv7(),       -- PG 18+
    inserted_by  text        NOT NULL DEFAULT current_user,
    sequence_num bigint      NOT NULL DEFAULT nextval('event_seq'),
    tags         text[]      NOT NULL DEFAULT ARRAY['default']
);

Erlaubte Default-Quellen:

  • Konstanten und Casts
  • VOLATILE-Funktionen (now(), gen_random_uuid(), nextval())
  • Sub-Expressions mit Operatoren

NICHT erlaubt: Subqueries (DEFAULT (SELECT ...)). Bei sowas Trigger nehmen.

Default überschreiben oder den Default explizit einsetzen

SQL
-- Default greift (status = 'pending')
INSERT INTO orders (customer_id, total) VALUES (1, 50);

-- Default explizit überschreiben
INSERT INTO orders (customer_id, total, status)
VALUES (1, 50, 'paid');

-- Default explizit anfordern
INSERT INTO orders (customer_id, total, status)
VALUES (1, 50, DEFAULT);

-- Bei UPDATE: zurück zum Default
UPDATE orders SET status = DEFAULT WHERE id = 42;

DEFAULT als Wert ist hilfreich bei generierten INSERT-Statements — du musst nicht wissen, was der Default-Wert ist.

NOT NULL nachträglich auf großer Tabelle

Klassische Falle: bei großen Tabellen scannt ALTER TABLE ... SET NOT NULL die ganze Tabelle mit ACCESS EXCLUSIVE Lock — alle Zugriffe blockiert.

Sicherer Pfad ab PG 12 mit CHECK-Constraint-Trick:

SQL Lock-armer Weg
-- 1. CHECK-Constraint mit NOT VALID anlegen (kurzer Lock, kein Scan)
ALTER TABLE orders
ADD CONSTRAINT orders_customer_id_not_null
CHECK (customer_id IS NOT NULL) NOT VALID;

-- 2. Bestand ggf. säubern (UPDATE der NULLs)
UPDATE orders SET customer_id = ... WHERE customer_id IS NULL;

-- 3. Constraint validieren (nur SHARE-Lock, kein ACCESS EXCLUSIVE)
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_id_not_null;

-- 4. NOT NULL setzen (Postgres erkennt validierten CHECK,
--    macht den Tabellen-Scan NICHT noch mal — instant!)
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;

-- 5. Aufräumen: doppelte Constraint kann weg
ALTER TABLE orders DROP CONSTRAINT orders_customer_id_not_null;

Schritt 4 ist auf modernen Postgres-Versionen instant, wenn ein gültiger CHECK-Constraint die NOT-NULL-Logik bereits abdeckt. Mehr im Artikel ALTER TABLE ohne Downtime.

Default nachträglich hinzufügen — die VOLATILE-Falle

Bis PG 10: ALTER TABLE … ADD COLUMN x int DEFAULT 5 schrieb für jede existierende Zeile den Default-Wert in die Tabelle — Tabellen-Scan, langer Lock, alles was man nicht will.

Seit PG 11: bei konstanten Defaults macht Postgres das nur logisch (Metadata) — kein Schreib-Vorgang auf bestehende Zeilen, instant.

SQL
-- INSTANT (PG 11+): konstanter Default
ALTER TABLE orders ADD COLUMN priority int NOT NULL DEFAULT 0;

-- LANGSAM: VOLATILE-Default macht weiterhin Tabellen-Scan
ALTER TABLE orders ADD COLUMN created_at timestamptz NOT NULL DEFAULT now();

now() ist VOLATILE — Postgres muss für jede Zeile auswerten, was zur Insert-Zeit gewesen wäre, und schreibt den Wert in die Zeile. Bei Millionen Zeilen sehr langsam.

Sicherer Pattern für VOLATILE-Defaults:

SQL Mehrstufig statt Big-Bang
-- 1. Spalte ohne NOT NULL hinzufügen (instant)
ALTER TABLE orders ADD COLUMN created_at timestamptz;

-- 2. Default für FUTURE-Inserts setzen (instant)
ALTER TABLE orders ALTER COLUMN created_at SET DEFAULT now();

-- 3. Bestand in Batches updaten (live, kein Lock auf der Tabelle)
UPDATE orders SET created_at = now() WHERE created_at IS NULL AND id < 100000;
UPDATE orders SET created_at = now() WHERE created_at IS NULL AND id < 200000;
-- ...

-- 4. NOT NULL via CHECK-Trick (siehe oben)

NOT NULL für IDENTITY und Generated Columns

GENERATED ALWAYS AS IDENTITY und GENERATED ALWAYS AS (...) STORED setzen NOT NULL automatisch — separate Angabe nicht nötig:

SQL
CREATE TABLE products (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    -- automatisch NOT NULL durch IDENTITY

    price        numeric(10,2) NOT NULL,
    tax_rate     numeric(4,2)  NOT NULL,
    price_with_tax numeric(10,2) GENERATED ALWAYS AS (price * (1 + tax_rate)) STORED
    -- automatisch NOT NULL, da deterministisch
);

Interessantes

NOT NULL ist Default — sage was anders ist.

Pragmatisch: jede Spalte sollte standardmäßig NOT NULL sein, NULL nur bei expliziter Bedeutung („unbekannt", „nicht gesetzt"). Schema-Designs ohne diese Disziplin haben oft NULLs überall, was Queries komplizierter macht (Three-Valued-Logic, NULL-Handling in Joins).

DEFAULT NULL ist redundant.

Spalten ohne expliziten Default haben automatisch NULL als impliziten Default. col text DEFAULT NULL ist also identisch zu col text — meistens ein Leftover aus MySQL-Migrationen.

Konstante Defaults sind instant beim ALTER ADD.

Seit PG 11 wird ein konstanter Default beim ADD COLUMN nur als Metadata gespeichert — kein Schreib-Vorgang auf existierende Zeilen. now(), random() und andere VOLATILE-Funktionen brechen die Optimierung allerdings.

now() in Default = Insert-Zeit (nicht Statement-Start).

DEFAULT now() in Postgres entspricht transaction_timestamp() — also Beginn der Transaktion. Wer den exakten Statement-Zeitpunkt braucht: statement_timestamp(). Wer den exakten Aufruf-Zeitpunkt: clock_timestamp(). In 99 % der Fälle ist now() richtig.

Generated Columns ersetzen oft Defaults + Trigger.

Wer eine Spalte als Funktion anderer Spalten will (price_with_tax), nicht mit Default + Trigger basteln, sondern GENERATED ALWAYS AS (...) STORED nehmen. Sauberer, kein Risiko von Drift. Mehr im Artikel Generated Columns.

Defaults für Surrogate-PKs überflüssig.

Bei id bigint GENERATED ALWAYS AS IDENTITY brauchst du kein zusätzliches DEFAULT nextval(...). Identity übernimmt das. Wer noch bigserial (= bigint NOT NULL DEFAULT nextval(...)) sieht: das ist die alte Syntax — gleiches Verhalten, weniger sauber.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Constraints & Schema-Design

Zur Übersicht