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
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:
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
-- 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:
-- 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.
-- 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:
-- 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:
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
- Default Values – PostgreSQL Documentation
- Not-Null Constraints
- Release Notes 11 – Instant ADD COLUMN