NOT-NULL-Spalte nachträglich hinzufügen — das Expand/Contract-Muster für befüllte Tabellen

Lokal lief der ALTER TABLE sauber durch — auf Staging bricht derselbe Deploy mit column "country_code" of relation "customer" contains null values. Der Unterschied ist nicht das SQL, sondern die Daten: Die lokale Tabelle war leer, die auf Staging nicht. Wer eine NOT-NULL-Spalte nachträglich zu einer befüllten Tabelle hinzufügen will, braucht mehr als ein Statement — er braucht die richtige Reihenfolge, und zwar über den ganzen Deploy hinweg.

Das Wichtigste vorab:

  • Warum der direkte Weg bricht: ADD COLUMN … NOT NULL schlägt auf jeder befüllten Umgebung fehl — und die DEFAULT-Abkürzung läuft zwar durch, schreibt aber stillschweigend einen Platzhalter in jede Bestandszeile.
  • Die Drei-Schritt-Sequenz: Spalte nullable anlegen → Backfill → SET NOT NULL — das Expand/Contract-Muster in seiner häufigsten Form.
  • Wie die Sequenz in ein Deploy-Modell zerfällt: Objekt-Datei plus Run-once-Transition — und was davon in Flyway oder Liquibase genauso gilt.
  • Greenfield-Sicherheit: dieselben Skripte müssen auch auf einer leeren, frisch aufgesetzten Datenbank durchlaufen.
  • Was SET NOT NULL auf großen Tabellen an Sperren kostet — und der Ausweg über CHECK … NOT VALID.

Voraussetzung: Postgres und ein SQL-Client. Alle Beispiele sind gegen Postgres 16 in einem Wegwerf-Docker-Container verifiziert; das Muster gilt ab Postgres 12, die meisten Aussagen schon ab Postgres 11. Die Deploy-Bausteine stammen aus dem offenen DI²-Starter-Kit auf GitHub.

Inhalt

Warum der direkte Weg bricht

Die Ausgangslage für alle Beispiele: eine Kundentabelle mit drei Bestandszeilen, wie sie auf jeder Umgebung nach dem Go-live liegt. Neu dazukommen soll die Spalte country_code — und sie soll NOT NULL sein.

  1: CREATE TABLE IF NOT EXISTS app.customer
  2: (
  3:     id     bigint         NOT NULL GENERATED ALWAYS AS IDENTITY
  4:    ,name   varchar(200)   NOT NULL
  5:
  6:    ,CONSTRAINT pk_customer PRIMARY KEY (id)
  7: );
  8:
  9: INSERT INTO app.customer
 10:     (
 11:         name
 12:     )
 13: VALUES
 14:     ('Alpha GmbH')
 15:    ,('Beta AG')
 16:    ,('Gamma KG');

Falle 1: NOT NULL ohne Default. Der naheliegendste Weg bricht sofort — Postgres müsste die neue Spalte in den drei Bestandszeilen mit NULL befüllen, und genau das verbietet das Constraint:

  1: ALTER TABLE app.customer ADD COLUMN country_code varchar(2) NOT NULL;
  2: -- ERROR:  column "country_code" of relation "customer" contains null values

Auf der leeren Entwicklungs-Datenbank fällt dieser Fehler nie auf. Er wartet auf die erste Umgebung mit Daten — im besten Fall Staging, im schlechtesten die Produktion.

Falle 2: NOT NULL mit DEFAULT. Die Abkürzung, die fast jede Suchmaschinen-Antwort empfiehlt, läuft durch — und genau das ist ihr Problem:

  1: ALTER TABLE app.customer ADD COLUMN country_code varchar(2) NOT NULL DEFAULT 'XX';
  2:
  3: SELECT
  4:     name
  5:    ,country_code
  6: FROM
  7:    app.customer;
  8: -- Alpha GmbH | XX
  9: -- Beta AG    | XX
 10: -- Gamma KG   | XX

Alle Bestandszeilen tragen jetzt den Platzhalter XX — ohne Fehlermeldung, ohne Log-Eintrag, ohne dass es je jemand entscheiden musste. Das ist in Ordnung, wenn der Default fachlich für jede Altzeile stimmt (ein is_verified boolean NOT NULL DEFAULT false etwa). Für eine Spalte wie country_code, deren Wert pro Zeile eigentlich aus den Daten kommen müsste, ist es eine stille Daten-Lüge: Das Constraint ist erfüllt, die Information fehlt trotzdem.

Ein verbreitetes Gegenargument zieht dabei nicht mehr: Seit Postgres 11 ist ADD COLUMN mit konstantem Default ein reiner Katalog-Eintrag — die Tabelle wird nicht umgeschrieben, egal wie groß sie ist. Die Falle ist also keine Performance-Falle, sondern eine semantische: Nicht der ALTER TABLE ist das Problem, sondern die Platzhalter-Werte, die er hinterlässt.

Falle 3: SET NOT NULL vor dem Backfill. Wer die beiden ersten Fallen kennt, trennt die Schritte — und tappt in die dritte, sobald die Reihenfolge nicht stimmt:

  1: ALTER TABLE app.customer ADD COLUMN IF NOT EXISTS country_code varchar(2) NULL;
  2:
  3: ALTER TABLE app.customer ALTER COLUMN country_code SET NOT NULL;
  4: -- ERROR:  column "country_code" of relation "customer" contains null values

Das passiert schneller, als es aussieht: Die Spalte steckt in der Tabellen-Definition, der Backfill in einem separaten Skript — und das Deploy-Werkzeug führt die Tabellen-Definition zuerst aus. Das Constraint greift, bevor die Daten da sind. Genau dieser Reihenfolge-Fehler ist der Grund, warum die Lösung auf einer befüllten Tabelle nicht ein einzelnes Statement ist, sondern eine Sequenz.

Die Drei-Schritt-Sequenz

So kommt die NOT-NULL-Spalte nachträglich auf jede Umgebung — befüllt oder leer:

  1. Expand: Die Spalte nullable anlegen — ALTER TABLE … ADD COLUMN country_code varchar(2) NULL.
  2. Backfill + Constraint: Die Bestandszeilen per UPDATE befüllen, dann — im selben Skript, direkt danach — ALTER COLUMN country_code SET NOT NULL.
  3. Contract: Nach dem Rollout den Soll-Zustand in der Schema-Definition nachziehen, damit sie die Tabelle wieder vollständig beschreibt.

Die Schritte 1 und 2 sind das eigentliche Expand/Contract-Muster: erst erweitern, ohne Bestehendes zu brechen — dann die Daten nachziehen — erst zum Schluss verengen. Das Constraint kommt als Letztes, wenn jede Zeile es erfüllen kann. Schritt 3 ist die Buchhaltung hinterher: Er ändert an der Datenbank nichts mehr, sorgt aber dafür, dass die versionierte Schema-Definition nicht dauerhaft hinter der Realität zurückbleibt.

Wie diese drei Schritte auf konkrete Dateien verteilt werden, hängt vom Deploy-Modell ab — das ist der nächste Abschnitt.

Schritt für Schritt im Deploy-Modell

Das Beispiel nutzt das konventionsbasierte Deploy-Modell aus SQL-Schema deployen ohne Migrations-ToolObjekt-Dateien beschreiben den Soll-Zustand jeder Tabelle und sind idempotent — sie laufen bei jedem Deploy. Run-once-Transitions erledigen datenabhängige Arbeit — sie laufen genau einmal pro Datenbank. Die Drei-Schritt-Sequenz verteilt sich exakt entlang dieser Trennlinie.

Schritt 1 — die Spalte in die Objekt-Datei, nullable:

  1: -- Konvergente Evolution: Spalten, die nach dem ersten CREATE dazukamen
  2: ALTER TABLE app.customer ADD COLUMN IF NOT EXISTS country_code varchar(2) NULL;

Das IF NOT EXISTS macht die Zeile beliebig wiederholbar: Eine Bestandsumgebung bekommt die Spalte beim nächsten Deploy dazu, eine Umgebung, die sie schon hat, überspringt sie. Wichtig ist das NULL am Ende — die Objekt-Datei darf das NOT NULL an dieser Stelle nicht setzen, sonst steht Falle 3 wieder im Raum: Die Objekt-Datei läuft vor dem Backfill.

Schritt 2 — Backfill und SET NOT NULL in eine Run-once-Transition:

  1: -- postdeploy/202607061000.backfill-customer-country-code.sql
  2: UPDATE app.customer
  3: SET
  4:    country_code = 'DE'
  5: WHERE
  6:    country_code IS NULL;
  7:
  8: -- Erst nach dem Backfill — am Ende desselben Skripts
  9: ALTER TABLE app.customer ALTER COLUMN country_code SET NOT NULL;

Der Backfill ist hier bewusst simpel gehalten — eine fachliche Entscheidung („Bestandskunden sind Inland“) als fester Wert. In der Praxis zieht das UPDATE den Wert oft aus vorhandenen Spalten oder einer Referenztabelle; am Muster ändert das nichts.

Entscheidend sind zwei Dinge: Das SET NOT NULL steht am Ende desselben Skripts — es kann also nie vor seinem Backfill laufen. Und das Skript läuft genau einmal pro Datenbank; danach sorgt das Constraint selbst dafür, dass keine neue Zeile ohne country_code entsteht. Wie der Deploy sich merkt, welche Transition schon gelaufen ist — Checksummen, Unveränderlichkeit, Tracking-Tabelle —, ist ein eigenes Thema: Schema-Änderungen tracken ohne Framework.

Schritt 3 — nach dem Rollout den Soll-Zustand nachziehen:

  1: -- Konvergente Evolution: Spalten, die nach dem ersten CREATE dazukamen
  2: ALTER TABLE app.customer ADD COLUMN IF NOT EXISTS country_code varchar(2) NULL;
  3: ALTER TABLE app.customer ALTER COLUMN country_code SET NOT NULL;

Sobald die Transition auf allen Umgebungen gelaufen ist, wandert das SET NOT NULL als idempotente Zeile in die Objekt-Datei. Auf Bestandsumgebungen ist die Zeile 3 ein No-op — die Spalte ist dort längst NOT NULL. Ihr Wert zeigt sich woanders: Die Objekt-Datei beschreibt die Tabelle wieder vollständig, und eine frisch aufgesetzte Umgebung hängt nicht am Effekt eines alten Transition-Skripts. Beide Läufe wurden gegen Docker-Postgres verifiziert — die Objekt-Datei zweimal hintereinander (Idempotenz), die Transition auf befüllter und auf leerer Datenbank.

Und mit Flyway oder Liquibase? Das Muster bleibt, die Datei-Aufteilung ändert sich. Weil ein Migrations-Tool ohnehin jede Migration genau einmal ausführt, passen alle drei Schritte technisch in eine Migration: Spalte nullable anlegen, Backfill, SET NOT NULL. Das trägt, solange die Anwendung, die die neue Spalte befüllt, im selben Zug deployt wird. Auf mehrere Migrationen aufteilen muss, wer während des Rollouts alte Anwendungs-Versionen weiterlaufen lässt: Dann schreibt der alte Code noch Zeilen ohne country_code, und das SET NOT NULL muss warten, bis alle Schreiber die Spalte kennen — expand in Release N, contract in Release N+1. Die Reihenfolge-Regel ist in beiden Welten dieselbe; nur die Grenze, an der sie durchgesetzt wird, verschiebt sich vom Datei-Typ zur Release-Planung.

Greenfield-Sicherheit

Ein Detail entscheidet darüber, ob die Transition ein Einweg-Skript ist oder ein dauerhaft gültiger Baustein: Sie muss auch auf einer leeren, aktuellen Datenbank fehlerfrei durchlaufen. Denn eine neue Umgebung — der Laptop des nächsten Entwicklers, ein frischer CI-Lauf, ein neues Staging — durchläuft beim Erst-Deploy dieselben Transitions, einmal, in chronologischer Reihenfolge.

Dafür sorgt im Backfill der WHERE-Guard:

  1: UPDATE app.customer
  2: SET
  3:    country_code = 'DE'
  4: WHERE
  5:    country_code IS NULL;

Auf der leeren Tabelle trifft das UPDATE null Zeilen und läuft als No-op durch; das anschließende SET NOT NULL gilt trivially — es gibt keine Zeile, die es verletzen könnte. Der Guard leistet dabei noch einen zweiten Dienst: Er überschreibt nie einen Wert, den ein Nutzer zwischen Spalten-Anlage und Backfill schon gesetzt hat.

Die Gegenprobe lohnt sich als fester Bestandteil des Workflows: das komplette Schema samt Transitions gegen einen Wegwerf-Container deployen, einmal auf leerer Datenbank, einmal doppelt hintereinander. Der Doppel-Deploy beweist die Idempotenz der Objekt-Dateien, der Leer-Deploy die Greenfield-Sicherheit der Transitions. In der CI lässt sich genau das als Pull-Request-Gate automatisieren: GitHub Actions für Postgres-Deploys.

Was SET NOT NULL auf großen Tabellen kostet

Ehrlichkeit gehört dazu: Die Drei-Schritt-Sequenz löst das Reihenfolge-Problem, nicht das Sperren-Problem. ALTER COLUMN … SET NOT NULL nimmt ein ACCESS EXCLUSIVE-Lock auf die Tabelle und hält es, während Postgres jede Zeile liest, um das Constraint zu prüfen. Für die Dauer dieses Scans blockiert die Tabelle vollständig — auch für Lesezugriffe. Wie lange das dauert, hängt von Tabellengröße, Storage und Cache ab — bei kleinen und mittleren Tabellen typischerweise Sekunden und im normalen Deploy-Fenster kein Thema; bei sehr großen Tabellen oder strengen Verfügbarkeits-Anforderungen wird der Scan unter Exklusiv-Sperre zum Risiko.

Dazu kommt ein Effekt, der in der Praxis oft schwerer wiegt als der Scan selbst: Das ACCESS EXCLUSIVE-Lock muss erst einmal frei werden. Läuft noch eine lange Transaktion auf der Tabelle, wartet der ALTER TABLE auf sie — und hinter dem wartenden ALTER staut sich jede weitere Abfrage, auch jedes SELECT. Ein gesetztes lock_timeout vor dem ALTER begrenzt dieses Risiko: Der Deploy bricht dann kontrolliert ab, statt die Anwendung zu blockieren.

Für diesen Fall bietet Postgres seit Version 12 einen Ausweg: Ein bereits validiertes CHECK-Constraint, das IS NOT NULL beweist, erspart dem SET NOT NULL den Scan. Die Sperr-Last verlagert sich damit in Operationen, die deutlich verträglicher sind:

  1: ALTER TABLE app.customer ADD CONSTRAINT chk_customer_country_code_nn
  2:    CHECK (country_code IS NOT NULL) NOT VALID;
  3:
  4: UPDATE app.customer
  5: SET
  6:    country_code = 'DE'
  7: WHERE
  8:    country_code IS NULL;
  9:
 10: ALTER TABLE app.customer VALIDATE CONSTRAINT chk_customer_country_code_nn;
 11:
 12: ALTER TABLE app.customer ALTER COLUMN country_code SET NOT NULL;
 13:
 14: ALTER TABLE app.customer DROP CONSTRAINT chk_customer_country_code_nn;

Die Zeilen 1 und 2 legen das Constraint als NOT VALID an — das ist ein kurzer Katalog-Eintrag ohne Daten-Prüfung, es gilt ab sofort nur für neue Zeilen. Das VALIDATE CONSTRAINT in Zeile 10 macht den teuren Scan, hält dabei aber nur eine Sperre, die parallele Lese- und Schreibzugriffe weiterlaufen lässt. Das SET NOT NULL in Zeile 12 findet dann ein validiertes Constraint vor und übernimmt es ohne eigenen Scan — die Exklusiv-Sperre dauert nur noch einen Augenblick. Zeile 14 räumt das Hilfs-Constraint wieder weg. Details in der Postgres-Dokumentation zu ALTER TABLE.

Für die meisten Tabellen ist dieser Fünf-Schritte-Tanz Overkill — die schlichte Transition aus dem vorigen Abschnitt reicht. Aber es ist gut zu wissen, dass das Muster skaliert, statt bei der ersten großen Tabelle zusammenzubrechen.

Verwandte Fälle — dasselbe Muster

Die Drei-Schritt-Sequenz ist der häufigste Vertreter einer ganzen Familie: Immer wenn eine Schema-Änderung von den Bestandsdaten abhängt, zerfällt sie in expand → Daten nachziehen → contract.

  • UNIQUE-Constraint nachziehen: Erst die Duplikate bereinigen (das Backfill-Gegenstück, als Run-once-Transition), dann das Constraint setzen. Wer es direkt setzt, erlebt Falle 3 in neuer Verkleidung: could not create unique index auf jeder Umgebung mit Dubletten.
  • Foreign Key nachziehen: Erst verwaiste Referenzen auflösen, dann den FOREIGN KEY anlegen — bei großen Tabellen wieder zweistufig als NOT VALID + VALIDATE CONSTRAINT.
  • Spaltentyp verengen (etwa varchar(100) auf varchar(2)): Erst die Werte auf das neue Format bringen, dann den Typ ändern. Wenn der Umbau größer ist: neue Spalte anlegen, befüllen, umschwenken, alte Spalte erst später droppen.
  • Spalte umbenennen bei laufendem Betrieb: RENAME COLUMN selbst ist ein reiner Katalog-Eintrag — aber alter Anwendungs-Code kennt nur den alten Namen. Zero-Downtime heißt hier: neue Spalte, Synchronisation, Code umstellen, alte Spalte droppen — expand/contract über zwei Releases.

Der gemeinsame Nenner: Das Constraint oder der Ziel-Zustand kommt immer zuletzt, nach den Daten. Wer diese eine Regel verinnerlicht, leitet sich die übrigen Fälle selbst her.

FAQ

Wie füge ich in Postgres eine NOT-NULL-Spalte zu einer bestehenden Tabelle hinzu?

In drei Schritten: die Spalte zunächst nullable anlegen (ALTER TABLE … ADD COLUMN … NULL), die Bestandszeilen per UPDATE befüllen, dann das Constraint mit ALTER TABLE … ALTER COLUMN … SET NOT NULL setzen. Der direkte Weg — ADD COLUMN … NOT NULL in einem Statement — schlägt fehl, sobald die Tabelle Zeilen enthält, weil Postgres die neue Spalte nicht mit NULL befüllen darf. Auf kleinen Tabellen dürfen alle drei Schritte in einer einzigen Transaktion laufen — DDL ist in Postgres transaktional; die Tabelle bleibt dann allerdings für die Dauer der Transaktion exklusiv gesperrt.

Warum nicht einfach ADD COLUMN … NOT NULL DEFAULT …?

Weil der Default dann stillschweigend in jeder Bestandszeile steht. Das ist richtig, wenn der Wert fachlich für alle Altzeilen gilt — etwa false für ein neues Flag. Es ist falsch, wenn der Wert eigentlich pro Zeile aus den Daten kommen müsste: Dann erfüllt ein Platzhalter zwar das Constraint, aber die Information fehlt weiterhin — nur schwerer erkennbar als vorher. Seit Postgres 11 ist die Operation übrigens auch auf großen Tabellen schnell; das Argument gegen sie ist semantisch, nicht technisch.

Was ist das Expand/Contract-Muster?

Eine Strategie, Schema-Änderungen in rückwärtskompatible Phasen zu zerlegen: erst erweitern (expand — die neue Spalte kommt nullable dazu, nichts Bestehendes bricht), dann Daten und Code nachziehen (der Backfill), erst am Ende verengen (contract — das NOT NULL, das Droppen der Alt-Spalte). Der Begriff stammt aus dem Umfeld von Zero-Downtime-Deployments und ist auch als Parallel Change bekannt.

Wie lange sperrt ALTER TABLE die Tabelle?

Das hängt vom Teilschritt ab. ADD COLUMN — nullable oder mit konstantem Default — ist seit Postgres 11 ein reiner Katalog-Eintrag: exklusive Sperre, aber nur für Millisekunden. SET NOT NULL hält die exklusive Sperre dagegen für einen kompletten Tabellen-Scan. Bei großen Tabellen lässt sich der Scan aus der Exklusiv-Sperre herausverlagern: CHECK … NOT VALID anlegen, per VALIDATE CONSTRAINT nebenläufig prüfen, dann greift SET NOT NULL ohne eigenen Scan — siehe den Abschnitt zu den Sperren.

Funktioniert das Muster auch in SQL Server?

Ja. Auch dort bricht ALTER TABLE … ADD … NOT NULL ohne Default auf einer befüllten Tabelle; die Default-Variante schreibt mit WITH VALUES den Platzhalter in die Bestandszeilen — dieselbe semantische Falle. Die saubere Sequenz ist identisch: Spalte nullable anlegen, per UPDATE befüllen, dann ALTER TABLE … ALTER COLUMN … NOT NULL (in SQL Server unter Angabe des vollständigen Datentyps). Ein Detail vorab prüfen: Ob das Hinzufügen einer NOT NULL DEFAULT-Spalte als reine Metadaten-Operation läuft oder die Tabelle umschreibt, hängt dort von Version, Edition und Default-Ausdruck ab.

Verwandte Artikel