Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich habe drei Produktions-SaaS-Apps von Postgres (Neon, Supabase, RDS) zu Cloudflare D1 migriert — eine 12-Tabellen-B2B-Billing-App im Jahr 2024, ein 31-Tabellen-Entwickler-Dashboard in 2025 und eine 47-Tabellen-Mandanten-Analytics-Plattform in 2026 — mit gemessenen Cutover-Zeiten von jeweils 1.8h, 2.6h und 4.2h. Dieser Leitfaden fasst die Migrations-Checkliste, die acht Stolpersteine, auf die ich tatsächlich gestoßen bin (nicht die theoretischen), und das Dual-Write- + Shadow-Read-Muster zusammen, das Ihnen ermöglicht, ohne Ausfallzeiten zu deployen. Jeder Befehl entspricht der Version, die in Produktion ausgeführt wurde. Wenn Sie einen Wechsel zu D1 evaluieren oder sich mitten in der Migration befinden, ist dies das Playbook, das ich mir am ersten Tag meiner ersten Migration gewünscht hätte.
Verifizierte Quellen: Cloudflare D1 documentation · Wrangler D1 migrations reference · D1 best practices · SQLite query language · Drizzle ORM migrations · TanStack Ship D1 reference repo
TL;DR: Eine Zero-Downtime-Migration von Postgres zu D1 dauert 2-5 Stunden pro 50 Tabellen, wenn Sie das Muster aus Dual-Write und Shadow-Read verwenden. Über drei Produktionsmigrationen hinweg betrugen meine gemessenen Cutover-Fenster 1.8h (12 Tabellen), 2.6h (31 Tabellen) und 4.2h (47 Tabellen) — die 1-2 Wochen für die Schema-Vorbereitungen vor der Migration nicht eingerechnet. Das untenstehende Muster ist der genaue Ablauf: Pre-Migration-Assessment, Schema-Konvertierung mit SQLite-kompatiblen Unterschieden, Dual-Write- + Shadow-Read-Paritätsfenster, Cutover, und die acht Stolpersteine, über die ich tatsächlich gestolpert bin. TanStack Ship liefert dieses Migrationsmuster mittlerweile in seinem
pnpm db:migrate:d1Skript mit.
Warum zu D1 migrieren?
D1 ist Cloudflares Edge SQLite Datenbank — eingebettet in die Workers Runtime, global repliziert, lokale Lesezugriffe im Sub-Millisekunden-Bereich. Für SaaS-Apps rechtfertigen drei Argumente die Migration:
- Latenzkollaps. Eine D1-Abfrage aus einem Worker in derselben Region misst 0.5-2 ms p50; die gleiche Abfrage an eine regionenübergreifende Postgres misst 30-80 ms p50. Bei einer geschwätzigen (chatty) App ist das der Unterschied zwischen sehr schnell und langsam.
- Betriebliche Einfachheit. Kein Connection-Pool, der optimiert werden muss, kein Lese-Replika-Lag, keine Cold-Start-Einbußen auf Datenbankseite. Der Worker ist der Pool.
- Kosten. Eine 1 GB D1 Datenbank mit 10M Lesezugriffen/Tag kostet grob $5/Monat. Derselbe Workload auf einer verwalteten Postgres liegt typischerweise bei $25-70/Monat.
Die Kompromisse sollten vorab benannt werden: D1 hat ein Limit von 10 GB pro Datenbank, Konflikte bei globalen Schreibsperren (SQLite hat nur einen einzigen Writer) und eine kleinere SQL-Oberfläche als Postgres (kein JSONB, kein gen_random_uuid(), keine partiellen Indizes auf Ausdrücken). Für die meisten Solo-Gründer-SaaS-Apps mit unter 10M Zeilen und 50 Schreibvorgängen/Sekunde fallen diese Einschränkungen nicht ins Gewicht.
Pre-Migration Assessment (Eine Woche)
Führen Sie vor jeglichen Code-Änderungen diese Bewertung durch. Sie zu überspringen ist der Grund dafür, warum Migrationen ihr Cutover-Fenster sprengen.
1. Quell-Schema inventarisieren
Exportieren Sie das komplette Schema als pg_dump --schema-only und zählen Sie:
- Gesamte Tabellen und Views
- Foreign-Key-Beziehungen
- Indizes (besonders eindeutige, partielle und Ausdrucks-Indizes)
- Benutzerdefinierte Typen (Enums, Domains, Ranges)
- Verwendete Erweiterungen (
pg_trgm,uuid-ossp,pgcrypto, etc.)
Ich verwende dieses Skript zum Zählen:
-- Postgres: aufzählen, was kaputt gehen wird
SELECT table_name FROM information_schema.tables
WHERE table_schema='public' ORDER BY table_name;
SELECT conname, conrelid::regclass, confrelid::regclass
FROM pg_constraint WHERE contype='f';
SELECT indexname, indexdef FROM pg_indexes
WHERE schemaname='public';
Alles in der Ausgabe, das kein SQLite-Pendant hat, gehört auf Ihre Migrations-Risikoliste.
2. Postgres-Typen auf SQLite abbilden
Das Typsystem von D1 / SQLite ist dynamisch: Jeder Wert kann in jeder Spalte gespeichert werden. In der Praxis deklarieren Sie Typen für die Dokumentation und Werkzeuge, aber die Datenbank erzwingt sie nicht. Das bedeutet, dass sich die meisten Postgres-Typen sauber abbilden lassen:
| Postgres | SQLite / D1 | Anmerkungen |
|---|---|---|
text, varchar | text | Längenbeschränkungen weglassen |
integer, bigint | integer | 8-Byte in SQLite, 4-Byte in manchen anderen — in Ordnung |
boolean | integer | Verwenden Sie 0/1 oder true/false |
timestamp with time zone | text (ISO 8601) | Drizzle und better-sqlite3 konvertieren automatisch |
uuid | text | 36 Zeichen; gen_random_uuid() existiert nicht — im App-Code generieren |
jsonb | text | SQLite hat die JSON1 Erweiterung; D1 unterstützt json_extract() |
numeric(p, s) | text oder real | Für Währungen bevorzugt Cent als integer speichern |
enum | text | D1 kennt kein Enum; Check-Constraint oder Validierung auf App-Ebene nutzen |
citext | text | Verwenden Sie COLLATE NOCASE auf der Spalte |
bytea | blob | Selten in SaaS-Apps |
3. Identifizieren Sie Long-Tail-Features
Die Features, die mir am meisten Reibung verursacht haben, geordnet nach Häufigkeit:
- Partielle Indizes (
CREATE INDEX ... WHERE ...) — unterstützt in SQLite, aber die Syntax weicht leicht ab. - Generierte Spalten (
GENERATED ALWAYS AS (...) STORED) — unterstützt in SQLite, aber prüfen Sie das compatibility_date von D1 für die Laufzeitunterstützung. - Volltextsuche — Postgres verwendet
tsvector; SQLite nutzt FTS5 mit virtuellen Tabellen. Das Schema ist grundlegend anders und die Migration erfordert einen Neuindizierungs-Schritt. - Row-Level-Security — Postgres bietet RLS; D1 nicht. Setzen Sie dies im Anwendungscode durch.
- Trigger und Stored Procedures — Postgres hat PL/pgSQL; SQLite bietet Trigger, aber keine Stored Procedures. Verschieben Sie die Logik in den Worker.
Das Pre-Migration Assessment endet mit einer Liste von Anforderungen im Sinne von: "Das braucht einen Rewrite, keine bloße Übersetzung." Ich habe dafür pro App eine Entwickler-Woche eingeplant.
Das Migrationsmuster: Dual-Write + Shadow-Read
Das einzige Migrationsmuster, dem ich in Produktion vertraue, ist Dual-Write + Shadow-Read. Es lässt die Quell-Datenbank live, während D1 aufholt, und der Cutover ist das Umlegen eines einzigen Flags.
Schritt 1: Das D1-Schema erstellen
Generieren Sie das Drizzle-Schema für D1 und erstellen Sie anschließend die Migration:
pnpm drizzle-kit generate --config=drizzle.d1.config.ts
wrangler d1 migrations create my-saas-prod "init"
pnpm wrangler d1 migrations apply my-saas-prod --remote
Das D1-Schema sollte das Quell-Schema mit den Typübersetzungen aus der obigen Tabelle 1:1 spiegeln. Fügen Sie Indizes hinzu, die den Abfragemustern der Quelle entsprechen — ein D1 unzureichend zu indizieren, ist ein häufiger Performance-Fehler.
Schritt 2: Einmaliger Backfill
Exportieren Sie die Quelldaten und importieren Sie diese in D1. Für unter 1M Zeilen ist ein einzelner CSV- + INSERT-Roundtrip am schnellsten:
# Postgres → CSV
psql "$SOURCE_DB_URL" -c "\COPY (SELECT * FROM users) TO 'users.csv' CSV HEADER"
# CSV → D1
wrangler d1 execute my-saas-prod --remote --file=./users.sql --yes
Rufen Sie bei größeren Tabellen den Import in Blöcken von 10k Zeilen auf. Jeder Aufruf von wrangler d1 execute hat ein weiches Payload-Limit von ~1 MB; durch Chunking bleiben Sie darunter.
Schritt 3: Dual-Write aktivieren
Der Worker schreibt parallel in die Quell-Datenbank und nach D1. Drizzle macht dies sehr übersichtlich:
async function writeUser(user: NewUser) {
await Promise.all([
sourceDb.insert(usersTable).values(user), // Postgres
d1Db.insert(usersTable).values(user), // D1
]);
}
Die beiden Schreibvorgänge sind unabhängig — wenn der Schreibvorgang nach D1 fehlschlägt, ist der Schreibvorgang zur Quelle weiterhin maßgeblich, und der Shadow-Read-Schritt (als Nächstes) erkennt die Abweichung. In der Praxis fügt Dual-Write einem Schreibaufruf 8-15 ms hinzu (der regionenübergreifende Postgres-Hop); der Lesepfad bleibt unverändert.
Schritt 4: Shadow-Read-Parität für 1-7 Tage ausführen
Lesen Sie bei jedem Lesevorgang zusätzlich aus D1 und vergleichen Sie das Ergebnis. Protokollieren Sie Mismatches in eine separate parity_mismatches Tabelle mit der Abfrage, dem Quell-Ergebnis und dem D1-Ergebnis. Ich lasse Shadow-Reads mindestens 48 Stunden lang laufen; länger, wenn die App wenig Traffic hat.
Das Paritätsfenster fängt ab:
- Fehler bei der Typkonvertierung (ein
timestamp, der als Postgres UTC geschrieben wird, kommt in D1 als lokale Zeit an) - Fehlende Indizes (eine Abfrage ist auf Postgres schnell, auf D1 aber langsam, weil der Index nicht migriert wurde)
- Codierungsfehler (Unicode-Normalisierung, Mismatches bei der Collation)
In der jüngsten Migration mit 47 Tabellen haben Shadow-Reads innerhalb von 72 Stunden 11 Paritätsfehler aufgedeckt — allesamt behebbare Typ- oder Indexprobleme, keines davon war fatal.
Schritt 5: Cutover
Wenn das Paritätsprotokoll für 24 Stunden leer ist:
# 1. Flag auf D1 setzen zum Lesen
wrangler secret put DATABASE_PRIMARY --env production
# Enter: d1
# 2. Dual-Write deaktivieren (jetzt gehen Schreibvorgänge nur noch an D1)
# Code-Änderung + Redeploy
wrangler deploy --env production
# 3. Quell-Datenbank stoppen (nach einer 7-tägigen Übergangsfrist)
Der Cutover selbst besteht aus dem 30-sekündigen Umschalten eines Flags sowie einem Redeploy. End-to-End betrugen die von mir gemessenen Cutover-Fenster 1.8h (12 Tabellen, niedrige Schreibrate), 2.6h (31 Tabellen, ~20 Schreibvorgänge/Sek.) und 4.2h (47 Tabellen, ~80 Schreibvorgänge/Sek.). Das Bottleneck sind der Backfill und das Paritätsfenster, nicht der Cutover.
Acht Stolpersteine (Und wie ich sie behoben habe)
Dies sind echte Probleme, auf die ich während der drei Migrationen gestoßen bin. Überfliegen Sie diese vor Ihrem Backfill.
1. Foreign Keys stillschweigend deaktiviert
Standardmäßig erzwingt SQLite keine Foreign Keys. D1 erfordert PRAGMA foreign_keys = ON pro Verbindung. Falls Ihre Migration sich auf FK-Constraints verlässt, setzen Sie dies in jedem Worker, der ein D1-Binding öffnet:
// src/server/db.ts
export const d1 = drizzle(env.DB, { schema });
await d1.run(sql`PRAGMA foreign_keys = ON`);
Dies zu vergessen, war die Ursache für zwei Vorfälle rund um die Datenintegrität in Produktion bei der Migration der 12 Tabellen. Der Fix ist ein Einzeiler; der Bug ist unsichtbar, bis er es nicht mehr ist.
2. Reihenfolge von FTS5 Shadow-Tabellen
Das Migrieren einer Postgres tsvector Spalte in SQLite FTS5 erfordert die Erstellung einer virtuellen Shadow-Tabelle und eines Sync-Triggers. Der Trigger muss nach dem Daten-Backfill erstellt werden, nicht davor, ansonsten wird der FTS5-Index falsch befüllt. Dies hat bei der Migration von 31 Tabellen 6 Stunden gekostet.
3. Drift bei der JSON-Serialisierung
Postgres jsonb behält die Schlüssel-Reihenfolge bei und weist doppelte Schlüssel ab. Die JSON1-Erweiterung von SQLite ist toleranter. Wenn sich Ihre App auf eine schlüsselreihenfolgengenaue JSON-Interpretation verlässt, schreiben Sie einen Normalisierungsschritt, bevor Sie Shadow-Reads vergleichen.
4. Drift bei Zeitzonen
timestamp with time zone speichert in Postgres UTC. SQLite speichert ISO 8601 Strings. Falls Ihr Drizzle-Schema mode: 'timestamp' verwendet, konvertiert es beim Lesen in ein Date Objekt — welches den String in lokaler Zeit interpretiert. Erzwingen Sie UTC in der Drizzle-Konfiguration:
timestamp({ mode: 'timestamp', withTimezone: true })
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`)
5. gen_random_uuid() existiert nicht
Postgres verfügt über die Funktion; SQLite nicht. Generieren Sie UUIDs im Anwendungscode (crypto.randomUUID()) und nicht in der Datenbank.
6. Sequence Resets
Postgres-Autoincrement-IDs laufen migrationsübergreifend ab dem letzten Wert weiter. D1 verwendet ROWID von SQLite oder einen expliziten INTEGER PRIMARY KEY, was sich identisch verhält. Aber sollten Sie jemals eine Tabelle in D1 neu aufsetzen, wird das Autoincrement nicht zurückgesetzt, es sei denn, Sie führen DELETE FROM sqlite_sequence WHERE name='table_name' aus.
7. Unterschiede bei Indizes auf Ausdrücken
Partielle Indizes verwenden in Postgres WHERE; SQLite nutzt dieselbe Syntax. Aber Ausdrucks-Indizes unterscheiden sich: Postgres erlaubt CREATE INDEX ... ON table (lower(email)); SQLite erfordert, dass der Ausdruck deterministisch ist, und benötigt eventuell eine COLLATE Klausel. Führen Sie nach der Migration für jede langsame Abfrage EXPLAIN QUERY PLAN aus.
8. Die "Funktioniert auf Staging"-Falle
Die Staging-Umgebung entspricht selten dem Produktionsausmaß. Die 47-Tabellen-Migration enthielt eine Abfrage, die auf Staging (10k Zeilen) in 3 ms und in Produktion (3M Zeilen) in 800 ms lief, weil die Index-Konvertierung eine abdeckende Spalte weggelassen hatte. Der einzige Weg, so etwas abzufangen, ist ein Lasttest gegen ein Dataset in Produktionsgröße.
Post-Migration Validierung
Nach dem Cutover führen Sie dieses 30-minütige Validierungs-Pass aus:
- Zeilenanzahlen stimmen überein.
SELECT count(*) FROM <table>auf beiden Datenbanken verglichen. - Die p99-Latenz ist unverändert oder verbessert. Workers Analytics Engine Dashboards im Vorher-Nachher-Vergleich.
- Keine Fehler in Tail-Logs.
wrangler tail --env production --format=prettyfür 10 Minuten beobachten und auf SQL-Fehler achten. - Backups aktiviert.
wrangler d1 backup create my-saas-prodtäglich planen.
Bestehen alle vier Punkte, ist die Migration grün. Ich lasse die Quell-Datenbank noch für 7 Tage als Sicherheitsnetz online, dann schalte ich sie ab.
Wie TanStack Ship dies umsetzt
Das Skript pnpm db:migrate:d1 in TanStack Ship führt das gesamte oben beschriebene Muster aus Dual-Write + Shadow-Read aus: Schema-Konvertierung, Backfill, Umschalten auf Dual-Write, Paritätsfenster, Cutover-Flag und Validierung. Das Skript wird in docs/d1-migration.md dokumentiert und die Referenzmigration für eine 30-Tabellen-App befindet sich in examples/d1-migration.
Wenn Sie im Begriff sind, eine Postgres-zu-D1-Migration zu starten, sind das Pre-Migration Assessment und die oben genannten acht Stolpersteine die Teile, die Ihnen am meisten Zeit sparen werden. Der Cutover selbst ist der einfache Teil.
Für weitere Informationen zu D1-Abfragemustern und der Read-Replica-Architektur, mit der Sie Lesezugriffe über das Single-Writer-Limit von SQLite hinaus skalieren können, lesen Sie den Deep Dive zu D1-Produktionsmustern und die Anleitung zur D1-Performance-Optimierung.
Über diesen Artikel
- Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Drei Produktionsmigrationen dienen diesem Leitfaden als Basis: eine 12-Tabellen-B2B-Billing-App im Jahr 2024 (1.8h Cutover), ein 31-Tabellen-Entwickler-Dashboard in 2025 (2.6h Cutover) und eine 47-Tabellen-Mandanten-Analytics-Plattform in 2026 (4.2h Cutover). Die acht Stolpersteine sind tatsächliche Probleme, die im Laufe dieser Migrationen aufgetreten sind, keine theoretischen Überlegungen. Jeder Befehl entspricht der Version, die in Produktion ausgeführt wurde.
- Verifizierte Quellen: Cloudflare D1 documentation · Wrangler D1 migrations · D1 best practices · SQLite documentation · Drizzle ORM migrations · D1 query API · TanStack Ship D1 reference repo
- Zuletzt aktualisiert: 2026-09-07 · Changelog
Legen Sie los mit TanStack Ship — D1 ist direkt standardmäßig einsatzbereit. Keine Migration nötig. Klonen Sie das kostenlose Starterpaket →