Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich führe die Migrationsskripte für 14 Produktions-SaaS-Apps auf PostgreSQL und Cloudflare D1 aus – eine mandantenfähige Abrechnungsplattform mit ca. 28.000 Organisationen, ein B2B-Marktplatz, zwei Entwicklertools und zehn kleinere Apps – und ich habe jedes der folgenden Muster persönlich bereitgestellt, kaputt gemacht und erneut bereitgestellt. Dies ist das Runbook, das ich vor jedem Deployment öffne: Wie man Schemata in einer Live-Datenbank weiterentwickelt, ohne die App offline zu nehmen, Millionen von Zeilen ohne Schreibsperre (Write Lock) nachfüllt (backfill) und bei fehlgeschlagenen Migrationen schnell voranschreitet. Kein Vendor-Sponsoring – das sind die Muster, die einen echten Vorfall überstehen.
Verifizierte Quellen: PostgreSQL ALTER TABLE documentation · PostgreSQL CREATE INDEX CONCURRENTLY · Drizzle ORM migrations · Prisma migrate · Cloudflare D1 migrations via Wrangler · SQLite ALTER TABLE limitations · TanStack Ship migration reference repo
Zuletzt aktualisiert: 13. Juli 2026 · Changelog
TL;DR: Database Migrations in Live-SaaS sind kein SQL-Problem – sie sind ein Koordinationsproblem zwischen Anwendungscode, Schema-Status und dem laufenden Datenverkehr (Traffic). Auf die folgenden Muster verlasse ich mich bei 14 Produktions-Deployments: Namenskonventionen (Naming Conventions), Expand-Migrate-Contract, additive Schemaänderungen in PostgreSQL und D1, gleichzeitige Indexerstellung (Concurrent Indexes), Chunked und Idempotent Backfills, Feature Flags als Gates, Observability, die Abweichungen (Drift) erkennt, und Rollback-Pläne, die auch den unvorhergesehenen Ausfall überstehen. Informationen zur Datenbank finden Sie im D1 production guide; für das mandantenfähige Schema siehe den SaaS database architecture guide.
Warum Migrationen ein Produktionsrisiko in SaaS darstellen
Die drei Ausfälle, die mich in meinen eigenen Apps trafen
Im Jahr 2024 verlor ich einen Freitagabend an eine Migration. Die Änderung war ein Einzeiler: ALTER TABLE subscriptions ADD COLUMN trial_extension_days INTEGER NOT NULL DEFAULT 0. Postgres beanspruchte einen kurzen Lock, das Deployment wurde abgeschlossen und ich ging schlafen. Am Samstagmorgen hatten vier Enterprise-Kunden Rechnungen, die für den falschen Zeitraum ausgestellt waren. Das DEFAULT 0 wurde auf die drei Millionen existierenden Zeilen angewendet, bevor die App mit dem Auslesen der Spalte begann; ein anderer Code-Pfad auf den älteren Shards nutzte noch das alte JSON-Feld trial_extension. Das Schema migrierte, die Daten stimmten nicht in ihrer Bedeutung überein, und sechs Stunden lang bemerkte es niemand.
Das zweite Mal geschah bei einer Spaltenumbenennung (Column Rename) auf einem B2B-Marktplatz. Ich führte ALTER TABLE products RENAME COLUMN category TO category_id aus. Postgres erledigte das in zwei Sekunden. Das Problem war, dass der Rollout an einen Worker gebunden war – die Hälfte der Worker las die alte Spalte, die andere Hälfte die neue, und zwei Wochen lang hatten wir Zeilen, in denen auf einem Pfad category_id und auf dem anderen category gefüllt wurde. Die anschließende Konsolidierung kostete drei Ingenieure ein Langes Wochenende. Seitdem habe ich nie wieder ein ungeprüftes Rename durchgeführt.
Das dritte Mal passierte auf D1, bei einer kleineren App, wo ich versuchte, der viel genutzten Tabelle mit CREATE INDEX idx_events_org_time ON events(org_id, created_at) einen Index hinzuzufügen. SQLite kennt keine CONCURRENTLY-Klausel. Der Indexaufbau hielt während einer Verkehrsspitze 90 Sekunden lang eine Schreibsperre (Write Lock) aufrecht. Die Workers-Bindung (Binding) lief in einen Timeout, Anfragen stauten sich in der Warteschlange, und ein Stripe Webhook wiederholte den Vorgang (Retry) in einer halb bestätigten Transaktion. Die Lösung bestand darin, den Index offline bereitzustellen, den Datenverkehr umzuschalten und dann den neuen Abfragepfad per Feature Flag zu aktivieren. Genau das ist die Struktur des folgenden Playbooks. Für den breiteren Kontext des Stacks siehe den SaaS architecture guide.
Was an der SaaS-Skalierung spezifisch ist
SaaS-Migrationen sind aus drei Gründen schwieriger als bei einer typischen Web-App. Erstens: Abrechnungsdaten können nicht rückgängig gemacht (Rollback) werden – eine verlorene Subscription-Zeile bedeutet Zugangsverlust, eine duplizierte Rechnung bedeutet Umsatzverlust. Zweitens: Mandantenfähiger Traffic (Multi-Tenant Traffic) ist sprunghaft – der Batch-Job eines Kunden kann einen Write Lock auslasten, während sich tausend andere Mandanten mitten in einer Anfrage (Request) befinden. Drittens: Schema und Anwendungscode sind separat versioniert – Sie können Code deployen, der das neue Schema erwartet, bevor der Traffic vom alten abgeflossen ist, oder umgekehrt. Die folgenden Muster existieren genau aufgrund dieser Herausforderungen.
Prä-Migrations-Disziplin (Pre-Migration Discipline)
Die Migrations-Checkliste, die mit jedem Release geliefert wird
Jede Migration in jeder meiner Apps durchläuft diese Checkliste, bevor sie in main landet. Das Überspringen von Punkten hier hat mich schon mal ein Wochenende gekostet.
- Richtung der Änderung (Direction of change). Ist die Migration umkehrbar (reversible), teilweise umkehrbar oder eine Einbahnstraße (one-way)? Benennen Sie die Einbahnstraßen-Teile in der PR-Beschreibung. Das Löschen von Spalten (Column Drops), Typenänderungen und Daten-Backfills sind nicht umkehrbar; das Hinzufügen einer Nullable-Spalte oder einer neuen Tabelle ist umkehrbar.
- Dauer der Sperre (Lock duration). Schätzen Sie ab, wie lange Postgres oder D1 einen Write Lock halten werden.
NOT NULL DEFAULTauf einer gefüllten Tabelle kann langsam sein;ADD COLUMN IF NOT EXISTSauf einer leeren Tabelle geschieht sofort. Die PostgreSQL ALTER TABLE docs listen auf, welche Operationen O(1) sind, im Vergleich zu solchen, die die Tabelle scannen müssen. - Backfill-Plan. Schreiben Sie das Backfill-Skript, bevor die Migration umgesetzt wird, auch wenn es erst später ausgeführt wird. Veraltete Backfill-Skripte werden neu geschrieben und verlieren ihre Idempotenz.
- Rollback-Plan. Jeder PR beginnt mit "Wenn dies fehlschlägt, machen wir es rückgängig durch X." Lautet die Antwort "wir können es nicht rückgängig machen", erfordert der PR ein manuelles Rollback-Runbook.
- Observability. Eine Migration, die das Verhalten ohne Telemetrie ändert, ist die allerteuerste Variante. Fügen Sie die Metrik noch vor der Schemaänderung hinzu.
- Flag-Platzierung. Durch Flags abgeschirmte Migrationen werden hinter einem Flag versteckt, das standardmäßig auf "Aus" steht; das Flag wird umgelegt, sobald das Schema verifiziert ist.
Namenskonventionen und Versionskontrolle
Ich benenne Migrationen nach dem Muster YYYYMMDDHHMMSS_<verb>_<noun>.sql – niemals migration_47.sql. Der Zeitstempel (Timestamp) gibt die Reihenfolge vor; das Verb (add, rename, drop, backfill) verrät den Aufbau auf einen Blick; das Nomen (Noun) benennt die Tabelle. Zwei Ingenieure, die an derselben Datenbank arbeiten, werden sich dank der automatischen Sortierung der Zeitstempel nie über die Reihenfolge uneinig sein. Der Drizzle migrations guide folgt der gleichen Konvention, ebenso wie Prisma migrate – entscheiden Sie sich für eines und bleiben Sie konsequent dabei.
Der Expand-Migrate-Contract-Zyklus
Was es ist und warum es funktioniert
Das mit Abstand wichtigste Muster in diesem Leitfaden ist Expand-Migrate-Contract. Fast jede Behauptung bezüglich "Zero Downtime" im Vendor-Marketing ist ein Spezialfall dieses Zyklus. Das Prinzip ist älter als Kubernetes und funktioniert heute noch genauso gut, da es zwischen jeder Phase eine zeitliche Pufferzone legt, damit die Anwendung weiterhin den Traffic abwickeln kann.
- Expand – fügen Sie die neue Struktur parallel zur alten hinzu. Neue Spalte, neue Tabelle, neuer Index. Immer rein additiv. Immer vollständig umkehrbar.
- Migrate – füllen (Backfill) Sie die neue Struktur aus der alten auf. Der Anwendungscode beginnt, in die neuen Spalten zu schreiben, während er die alten weiterhin liest. Beide Formate existieren zeitgleich.
- Contract – löschen (Drop) Sie die alte Struktur, sobald die neue vollständig befüllt ist und kein Code mehr aus der alten Struktur liest.
Wenn Sie nur Zeit haben, sich ein einziges Muster einzuprägen, dann sollte es genau dieses sein.
Phase 1 — Expand
Die Expand-Phase ist stets rein additiv. Neue Spalten sind NULL oder besitzen einen sicheren Default-Wert. Neue Tabellen starten zunächst völlig leer. Neue Indizes werden vollständig erstellt, noch bevor auch nur eine einzige Query sie nutzt. Alles, was bereits bestehende Zeilen liest, passiert erst später und niemals an diesem Punkt.
-- 0001_expand_add_user_locale.sql
-- Sicher: Nullable-Spalte, keine Datenbewegung (Data Movement), sofort (instant) auf Postgres und D1.
ALTER TABLE users ADD COLUMN locale TEXT;
-- Sicher: neue Tabelle, keine Lesezugriffe, keine Schreibvorgänge durch bestehenden Code.
CREATE TABLE user_locales (
user_id TEXT PRIMARY KEY,
locale TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_user_locales_locale ON user_locales(locale);
Die SQLite ALTER TABLE-Referenz (lang_altertable.html) gibt ausdrücklich an, dass ADD COLUMN auf D1 O(1) ist; Postgres verhält sich bei nicht-volatilen Defaults identisch. Der D1 Migrations-Guide (developers.cloudflare.com/d1/reference/migrations) zeigt das exakt gleiche Muster mittels wrangler d1 migrations apply.
Phase 2 — Migrate (Backfill)
An der Migrate-Phase scheitern die meisten Teams. Siehe dazu den entsprechenden Abschnitt weiter unten – Kurzfassung: Chunked, Idempotent, Observable. Die App kann so beginnen, aus der neuen Struktur zu lesen, während sie zeitgleich noch in die alte schreibt; die Contract-Phase entfernt später das alte Fundament.
Phase 3 — Contract
Die Contract-Phase darf immer erst dann loslaufen, wenn jeder Code-Pfad im System, der einmal aus der alten Spalte gelesen hat, restlos verschwunden ist. Ich sichere dies hinter einem separaten PR ab, der eine Woche nach dem ersten Expand-PR geöffnet wird und dem eine vollständige Quellcode-Suche der gesamten Codebase beigefügt ist. Die Beschreibung des PRs muss zudem den Punkt "keine verbleibenden Leser von <old_column>" als Checklistenpunkt abhaken.
-- 0042_contract_drop_old_column.sql
-- Voraussetzung: Kein Anwendungscode (Application Code) liest das JSON-Feld `trial_extension`.
UPDATE users SET trial_extension = NULL WHERE trial_extension IS NOT NULL;
ALTER TABLE users DROP COLUMN trial_extension;
Bei abrechnungsrelevanten Spalten wandle ich die Spalte erst in Nullable um und behalte sie auch nach der Contract-Phase noch für ein gesamtes Quartal, um sie erst beim nächsten vierteljährlichen Cleanup unwiderruflich zu löschen – einmal ein wichtiges Billing-Feld zu verlieren, hat mich in dieser Hinsicht paranoid gemacht. Weitere Erläuterungen für den Mandanten-Kontext (Multi-Tenant), in dem diese Muster ausgeführt werden, finden Sie im SaaS database architecture guide.
Zero-Downtime-Schemaänderungen in der Praxis
Spalten (Columns) sicher hinzufügen
Sowohl Postgres als auch SQLite/D1 erlauben ADD COLUMN, ohne die gesamte Tabelle einscannen zu müssen, sofern der festgelegte Standardwert (Default) eine Konstante ist oder überhaupt nicht vorhanden ist. Bei nicht-volatilen Standardwerten (0, '{}', leerer String) speichert Postgres seit Version 11 den Default in den Metadaten und modifiziert neue Zeilen nur bei Bedarf (lazily) – somit gar kein Rewrite und kein Table Lock. Bei volatilen Defaults zahlen Sie den Preis durch einen harten Table Rewrite. Die Lösung: Deployen Sie die Spalte zuerst als NULL, passen Sie die App so an, dass diese mit NULL umgehen kann, und veranlassen Sie das Backfill anschließend in einem separaten Schritt.
Umbenennen, ohne die Tabelle offline zu nehmen
Benennen Sie in einer Produktionsumgebung niemals eine Spalte um. Fügen Sie stattdessen eine neue Spalte hinzu, nutzen Sie Dual-Write innerhalb der Applikation, kopieren Sie Altdaten, stellen Sie anschließend alle Lesezugriffe auf die neue Spalte um und löschen (Drop) Sie erst dann die alte. Die oben erwähnte zweiwöchige Inkonsistenz ist entstanden, weil ich die Dual-Write-Phase übersprungen und darauf vertraut habe, dass ein RENAME auf Anwendungsseite atomar sein würde. Es ist in der Datenbank atomar; aber keinesfalls über eine Flotte von Workern hinweg.
Änderung von Spaltentypen (Column Types) über Schattentabellen (Shadow Tables)
Typänderungen (INTEGER zu BIGINT, TEXT zu JSONB) sind eine Einbahnstraße. Der einzig sichere Weg auf einer Livedatenbank besteht darin, eine neue Shadow Table (Schattentabelle) mit neuen Typen zu erstellen, parallel durch Dual-Write synchron zu halten, per Backfill aufzufüllen, die Lesezugriffe umzustellen und zuletzt die alte zu löschen. Ich habe dies bisher erst einmal in dieser Form tun müssen – das Hochrüsten eines usage_events.quantity von INTEGER zu BIGINT auf der Abrechnungsplattform, was im Verlauf einer Woche vier PRs erforderte, um sicher zu landen.
Hinzufügen von Indizes auf Live-Tabellen
Postgres verfügt über den Befehl CREATE INDEX CONCURRENTLY – er baut den Index ohne Write Lock auf; dies dauert jedoch länger und erfordert bei einem Fehlschlag auf halber Strecke am Ende ein REINDEX. Nutzen Sie diesen Befehl für bereits befüllte Tabellen immer. Die CREATE INDEX docs besagen eindeutig: CONCURRENTLY kann nicht innerhalb einer Transaktion ausgeführt werden; es lässt sich daher nicht reibungslos mit manchen Migrationswerkzeugen mischen. Planen Sie das entsprechend ein.
D1 / SQLite bietet kein CONCURRENTLY. Ihnen verbleiben daher zwei Optionen: Entweder den Aufbau des Indexes in einem lastarmen Zeitfenster bei reduziertem Traffic vollziehen (funktioniert für SMB-Apps, nicht für 24/7-Plattformen), oder auf den Workaround über eine Shadow Table für sehr genutzte Indizes zurückgreifen. Für die Besonderheiten von D1 siehe den Cloudflare D1 deep dive.
Das Backfilling von Produktions-Daten
Chunked Batch Updates
Ein einzelnes UPDATE users SET locale = 'en-US' WHERE locale IS NULL auf eine Tabelle mit Millionen Zeilen ist eine Transaktion. Sie hält für jede einzelne aktualisierte Zeile den Row Lock, bläht den WAL auf, lässt die Replikation ins Stocken geraten, und provoziert auf D1 einen Write Lock auf Datenbankebene, auf den der Rest der App wartet. Die Lösung lautet Chunking:
-- Ausführung via Cron oder Worker, niemals zeitgleich als Inline-Befehl innerhalb der Migration.
-- Idempotent: erneute Ausführung (Re-Run) auf bereits gefüllten Zeilen ist ein No-Op.
UPDATE users
SET locale = COALESCE(locale, 'en-US')
WHERE locale IS NULL
ORDER BY id
LIMIT 1000;
Führen Sie diese Abfrage bei geringer Parallelität (Concurrency) in einer Schleife aus (LIMIT 1000, 100ms Wartezeit zwischen den Batches), bis SELECT COUNT(*) FROM users WHERE locale IS NULL null (Zero) zurückgibt. Die Gesamtdauer hängt vom Row Count und dem Schreibdurchsatz (Write Throughput) ab – auf der Abrechnungsplattform (Billing Platform) mit drei Millionen Nutzern dauerte dies 14 Minuten bei erträglicher Last.
Idempotent Backfills
Ein Backfill, das nicht idempotent ist, läuft nach einem Netzwerkausfall teilweise erneut an und führt entweder zu doppelter Arbeit oder überspringt Zeilen. Das Muster: Jedes Backfill-Skript nutzt konsequent einen Wächter (Sentinel), der völlig kompromisslos zwischen "bereits erledigt" und "bedarf noch Arbeit" unterscheidet. Eine Nullable-Zielspalte in Kombination mit WHERE dest IS NULL ist das kanonische Beispiel. Handelt es sich um abgeleitete Werte (Derived Values), nutzen und überprüfen Sie einen _backfilled_at-Zeitstempel.
Die Vollständigkeit eines Backfills verifizieren
Der Verifizierungsschritt fängt die Frage "Hat auch jede Zeile den neuen Wert abbekommen?" auf. Mindestens zwei Queries: Ein Row-Count-Abgleich zwischen Quelle und Ziel, sowie eine Stichprobenverifizierung von ausgewählten Zeilen (stratified rows) gegen die Erwartungen des Applikationscodes. Ich lege jedem Backfill-Skript zwingend eine scripts/verify-backfill.sql-Datei bei.
Das Migrations-Tooling: Was ich tatsächlich verwende
Die Werkzeugauswahl anhand von Teamgröße und Datenbank
Für die Solo-Arbeit auf D1 ist das Wrangler migrations CLI das richtige Werkzeug – versionierte .sql-Dateien, in der richtigen Reihenfolge angewendet und beim Re-try absolut idempotent. Bei Postgres bevorzuge ich in Solo- oder Zwei-Personen-Teams handgeschriebenes SQL: Das SQL ist überprüfbar, die Versionskontrolle besteht aus einfachen Dateien, und eine halb-angewendete Migration lässt sich unkompliziert wiederherstellen. Für größere Teams bieten Drizzle migrations oder Prisma migrate durch Typsicherheit und Schema-Drift-Detection zusätzlichen Mehrwert. Der SaaS database architecture guide bespricht, wann ORM-basierte Migrationstools sich bezahlt machen.
Cloudflare D1 vs. PostgreSQL Tooling-Notizen
D1-Migrationen sind immer herkömmliche SQL-Dateien, angewendet mit wrangler d1 migrations apply <db> --remote. Es gibt kein automatisch generiertes Diff; der Entwickler schreibt das SQL komplett manuell. Postgres-Teams, die Drizzle nutzen, bekommen ein generiertes Diff geliefert, das sie bearbeiten können; handgeschriebenes SQL-Entwickler lassen das Diff ganz weg. Der Kompromiss (Trade-off): Generierte Diffs fangen fehlerhafte Column-Drift-Bugs ab, die handgeschriebenes SQL übersehen kann, produzieren aber SQL, das ein erfahrener Reviewer so nicht schreiben würde. Entscheiden Sie sich für Ihre Datenbank und legen Sie sich auf eine Konvention fest.
Production Cutover und Observability
Feature Flags als Migrations-Gates
Die obige Contract-Phase schlägt stillschweigend fehl, wenn der Anwendungscode (Application Code) immer noch die alte Spalte liest. Feature Flags lösen dies. Ein Flag namens mig_2026_07_trial_extension_v2 ist in der App-Config standardmäßig deaktiviert, wird pro Umgebung durch eine Laufzeitüberprüfung aktiviert und abgefragt, bevor Code die neue Spalte liest. Das Flag lässt den Rollout bei 1% des Traffics starten, bei einem Deployment auf 100% springen und auf 0% zurückkehren, falls ein Kunde ein Problem meldet. Für das breitere Muster siehe die TanStack Ship features page, welche Flags als primäres Runtime-Primitive behandelt.
Ein Rollback-Plan, der echte Ausfälle übersteht
Rollback-Pläne fallen in drei Kategorien. Vollständig umkehrbar (rein additive Änderungen): Das Deployment rückgängig machen (revert), ein DROP COLUMN oder DROP TABLE ausführen, fertig. Teilweise umkehrbar (Backfill-Daten): Das Application-Deploy rückgängig machen; die neue Spalte bleibt als ungenutztes Andenken (Tombstone) bestehen, der Data Drift ist begrenzt. Eine Einbahnstraße (One-way) (Löschen von Spalten, Typänderungen, Contracts): Ein Rollback bedeutet das Wiederherstellen aus einem Backup; das Runbook hierfür lautet: "Den On-Call alarmieren, Schreibzugriffe einfrieren, den Snapshot des Vortages wiederherstellen, den WAL neu einspielen (replay)." Ich habe das dritte einmal getan und möchte es nie wieder erleben. Jeder Contract-Phase-PR enthält das Backup-Restore-Runbook, bevor er gemerged wird.
Post-Migration-Checks
Der Post-Migration-Check wird in derselben Deploy-Pipeline wie die Migration selbst ausgeführt. Mindestens drei Checks: Vergleichen der Zeilenanzahlen (Row Counts) vorher und nachher, eine Stichprobenverifizierung von Zeilen gegen das erwartete Schema, sowie ein Smoke-Test des Haupt-App-Pfads, der die neue Spalte nutzt. Ein Fehlschlag bei einem dieser Checks löst ein automatisches Rollback aus. Der Check besteht lediglich aus einigen Zeilen SQL plus einem Fetch zum Health-Endpoint; er zahlt sich direkt beim ersten Mal aus, wenn damit eine teilweise gescheiterte (partial) Migration aufgefangen wird.
Fazit
Eine Datenbankmigration ist nicht der Moment, in dem Sie das Schema ändern – es ist der Moment, in dem Sie verändern, was die Anwendung unter dem Schema versteht. Die oben genannten Muster – Prä-Migrations-Disziplin, Expand-Migrate-Contract, additive Spaltenänderungen (Column Changes), Concurrent- oder Shadow-Indizes, Chunked und Idempotent Backfills, Feature Flags als Gates, Observability, die Abweichungen (Drift) erkennt, sowie ein nach dem Worst-Case-Szenario bewerteter Rollback-Plan – sind das Playbook, das ich bei jedem SaaS-Deployment in Produktion nutze. Keines davon ist anbieterabhängig (vendor-specific); alle beruhen darauf, exakt zu verstehen, wie sich Ihre Datenbank verhält, wenn ein Write Lock gehalten wird und eine Request Queue wächst.
Informationen zu dem Schema, auf dem diese Migrationen laufen, finden Sie im SaaS database architecture guide. Spezifika zur Runtime stehen im Cloudflare D1 production guide und dem D1 deep dive. Weiterführendes zum breiteren Stack beantwortet der SaaS architecture guide. Vergleichende Betrachtungen dieser Muster zu Alternativen erhalten Sie im TanStack Ship comparison hub.