PostgreSQL Schema-Design für SaaS: Die frühen Entscheidungen, die deine Skalierungsgrenze festlegen
Die meisten SaaS-Datenbanken entstehen unter Zeitdruck. Das Team will ausliefern, das Schema wird an einem Nachmittag skizziert, und die Migrationen stapeln sich von da an. Das ist normal und nicht einmal falsch, denn Geschwindigkeit bis zum Launch zählt. Das Problem ist: Eine Handvoll Schema-Entscheidungen aus Woche eins ist praktisch nicht mehr umkehrbar, sobald du Produktionsdaten, zahlende Kunden und ein laufendes System hast, das du nicht abschalten kannst.
Gutes PostgreSQL Schema-Design für SaaS bedeutet nicht, von Anfang an alles richtig zu machen. Es bedeutet, die acht Entscheidungen zu erkennen, die später teuer zu ändern sind, und deine begrenzte Design-Zeit genau dort zu investieren. Nach Audits von dutzenden SaaS-Datenbanken in unterschiedlichem Gesundheitszustand sehen wir immer wieder dieselben Weichenstellungen, die leise skalierende Schemas von jenen trennen, die im schlechtesten Moment ein Migrationsprojekt erzwingen.
1. ID-Strategie: UUID vs. BIGSERIAL vs. ULID
Der Typ des Primärschlüssels ist die Entscheidung mit der größten Ansteckungswirkung in deinem Schema. Jeder Fremdschlüssel, jeder Index, jede URL und jede API-Antwort erbt ihn.
BIGSERIAL ist kompakt (8 Byte), natürlich sortiert und indexfreundlich. Die Nachteile: IDs sind erratbar (Enumeration-Angriffe gegen /invoices/10432 sind ein reales Audit-Ergebnis), und sie verraten jedem, der sich zweimal registriert und die IDs vergleicht, dein Geschäftsvolumen.
Zufällige UUIDs (v4) beheben die Erratbarkeit, schaden aber bei Skalierung auf eine Weise, die Teams selten einplanen: Inserts landen an zufälligen Positionen im B-Tree-Index. Sobald der Index nicht mehr in den Speicher passt, berührt jeder Insert eine kalte Page. Schreiblastige Tabellen mit UUIDv4-Primärschlüsseln zeigen messbar schlechteren Insert-Durchsatz und deutlich größere Indizes als sequentielle Schlüssel.
ULIDs und UUIDv7 geben dir beides: zufällig genug, um nicht erratbar zu sein, und zeitlich sortiert, sodass Inserts wie bei einer Sequenz an den rechten Rand des Index angehängt werden. PostgreSQL 18 liefert uuidv7() nativ mit. Auf älteren Versionen erledigt das eine kleine Extension oder die Generierung auf Anwendungsseite.
Unsere Standardempfehlung: UUIDv7 (oder ULID als UUID gespeichert) für alles, was in URLs oder APIs auftaucht, BIGSERIAL für interne Tabellen mit hohem Volumen wie Event-Logs, die das Backend nie verlassen.
2. Soft Delete vs. Hard Delete, und was die DSGVO daraus macht
Die Spalte deleted_at fühlt sich sicher an: Nichts ist je wirklich weg, Wiederherstellungen sind trivial. Aber Soft Deletes haben sich aufsummierende Kosten. Jede Abfrage braucht einen WHERE deleted_at IS NULL-Filter, den jedes ORM gelegentlich vergisst. Unique Constraints funktionieren nicht mehr ("diese E-Mail ist bereits vergeben", von einem gelöschten Konto), was partielle Unique-Indizes erzwingt. Und Tabellen sammeln tote Zeilen an, die jeden Index und jeden Scan aufblähen.
Dann kommt die DSGVO. Löschanfragen nach Artikel 17 akzeptieren "wir haben ein Flag gesetzt" nicht als Löschung personenbezogener Daten. Wenn du deine gesamte Anwendung auf Soft Deletes gebaut hast, brauchst du jetzt einen zweiten Mechanismus, der personenbezogene Daten über jede Tabelle hinweg, die auf einen Nutzer verweist, tatsächlich entfernt oder anonymisiert. Also genau die Hard-Delete-Kaskade, die du vermeiden wolltest.
Das Muster, das hält: personenbezogene Daten hart löschen (mit anonymisierter Tombstone-Zeile, falls die referenzielle Integrität es verlangt), Geschäftsobjekte wie Projekte oder Dokumente soft löschen, wo Wiederherstellung ein echtes Produktfeature ist. Entscheide pro Tabelle, nicht global. Die Mechanik der Löschung haben wir ausführlich in unserem Leitfaden zum DSGVO-Recht auf Löschung behandelt.
3. Audit-Spalten und wo sie unter asynchronen Workern brechen
created_at, updated_at, created_by: Jede Tabelle bekommt sie, meist über ORM-Listener. Zwei Fehlermodi tauchen in Produktion auf.
Erstens greifen ORM-verwaltete Zeitstempel nur, wenn Schreibvorgänge durch das ORM laufen. Sobald du ein Bulk-UPDATE in rohem SQL, einen COPY-Import oder eine Kaskade auf Datenbankebene ergänzt, hört updated_at still auf, wahr zu sein. Wenn irgendetwas nachgelagert auf updated_at synchronisiert (Suchindexierung, Cache-Invalidierung, Exporte), werden diese Zeilen nie synchronisiert. Nutze Datenbank-Trigger oder DEFAULT now() auf Schema-Ebene, statt der Anwendungsschicht zu vertrauen.
Zweitens bedeutet created_by üblicherweise "der aktuell authentifizierte Nutzer", was in einem asynchronen Worker bedeutungslos ist. Ein Queue-Consumer, der einen Job abarbeitet, hat keinen Request-Kontext. Frameworks schreiben deshalb entweder NULL oder, schlimmer, den letzten Nutzer, an den sich ein veralteter Kontext erinnert. Modelliere Akteure explizit: ein Diskriminator created_by_type (user, system, api_key) neben der ID, festgelegt beim Einstellen des Jobs, nicht bei seiner Ausführung.
4. Zeitstempel: immer timestamptz, und eine Regel mehr
Die Kurzfassung, die alle kennen: Nutze timestamptz, nie timestamp. Ein einfaches timestamp speichert einen Wanduhrwert ohne Zone, und beim ersten Mal, wenn Server, CI-Runner und Entwicklerlaptop sich über Zeitzonen uneinig sind, bekommst du Daten, die nicht mehr zu reparieren sind, weil die ursprüngliche Zoneninformation nie gespeichert wurde.
Die Regel, die Teams übersehen: timestamptz speichert einen Zeitpunkt, keine lokale Zeit. Für alles, was zu einer lokalen Wanduhrzeit wiederkehren muss (den Report um 09:00 Uhr in der Zeitzone des Kunden senden), speichere den Zeitzonennamen (Europe/Berlin, nicht einen Offset) in einer eigenen Spalte und berechne den Zeitpunkt beim Planen. Offsets brechen zweimal im Jahr bei der Zeitumstellung, Zonennamen nicht.
5. JSONB vs. normalisierte Spalten
JSONB ist die Notluke, die PostgreSQL für flexible, kundendefinierte Attribute tragfähig macht, und sie wird routinemäßig überstrapaziert. Die Heuristik, die funktioniert: Wenn deine Anwendungslogik auf ein Feld verzweigt, ist es eine Spalte. Wenn du danach filterst, sortierst oder joinst, ist es eine Spalte. JSONB ist für Daten, deren Form du nicht kontrollierst (Webhook-Payloads, Antworten von Drittanbieter-APIs), und für wirklich dynamische Custom Fields pro Mandant.
Die Kosten, die später auftauchen: keine echten Fremdschlüssel aus einem Dokument heraus, keine Spaltenstatistiken für den Planner (JSONB-Prädikate bekommen generische Selektivitätsschätzungen, was auf großen Tabellen schlechte Pläne erzeugt), und jeder Schreibvorgang schreibt das gesamte Dokument neu, was das WAL aufbläht und schlecht mit häufigen kleinen Updates zusammenspielt. Ein GIN-Index hilft bei Containment-Abfragen, ist auf schreiblastigen Tabellen aber teuer in der Pflege.
6. Enums vs. Lookup-Tabellen, und die Migration im laufenden Betrieb
Native PostgreSQL-Enums sind kompakt und selbstdokumentierend, aber im laufenden System schmerzhaft: Du kannst keinen Wert entfernen, Umbenennungen sind umständlich, und vor PostgreSQL 12 war selbst das Hinzufügen eines Werts innerhalb einer Transaktion eingeschränkt. Lookup-Tabellen sind flexibler, kosten aber einen Join und lassen inkonsistente Daten einsickern, wenn niemand die Referenz erzwingt.
Für SaaS ist oft eine dritte Option am besten: eine einfache text-Spalte mit einem CHECK-Constraint. Einen Wert hinzuzufügen heißt, das Constraint zu droppen und neu anzulegen, was mit NOT VALID gefolgt von VALIDATE CONSTRAINT sofort geht. Die Werte bleiben in jeder Abfrage und jedem Export lesbar.
Wenn du auf einer großen Live-Tabelle mit einem nativen Enum feststeckst, ist der Migrationsweg Expand-Contract: Textspalte hinzufügen, in Batches backfillen, aus der Anwendung doppelt schreiben, Lesevorgänge umstellen, dann die Enum-Spalte droppen. Dieselbe Choreografie, die wir in unserem Playbook für Zero-Downtime-Migrationen beschreiben, gilt hier direkt.
7. Multi-Tenant-Index-Strategie: tenant_id führt
In einer Multi-Tenant-Datenbank mit gemeinsamem Schema trägt fast jede Abfrage ein WHERE tenant_id = ?. Das hat eine deutliche Konsequenz für PostgreSQL Schema-Design in SaaS-Produkten: Zusammengesetzte Indizes sollten mit tenant_id beginnen, denn ein Index auf (tenant_id, created_at) bedient die Listenabfrage pro Mandant, während getrennte Einzelspalten-Indizes bestenfalls ein Bitmap-AND erzwingen.
Mit tenant_id zu beginnen gibt dir außerdem Lokalität: Die Zeilen eines Mandanten liegen in denselben Index-Pages, sodass das Working Set eines aktiven Mandanten heiß bleibt. Es bleibt kompatibel mit Row-Level-Security-Policies und eröffnet dir einen sauberen Weg zur späteren Partitionierung nach Mandant, ohne Abfragen umzuschreiben. Der Fehler, den wir in Audits am häufigsten finden, ist eine große Tabelle mit acht Einzelspalten-Indizes, von denen keiner mit tenant_id beginnt, und ein Query-Log voller 200-ms-Scans, die 2-ms-Lookups sein sollten.
8. Fremdschlüssel vs. weiche Referenzen für event-förmige Daten
Fremdschlüssel sind für relationale Kerndaten nicht verhandelbar. Aber für event-förmige Tabellen (Audit-Logs, Aktivitäts-Feeds, Analytics-Events) erzeugt ein strikter Fremdschlüssel auf die Nutzer- oder Bestellzeile echte Probleme: Du kannst die referenzierte Zeile nicht löschen oder archivieren, ohne in deine Historie zu kaskadieren, jeder Insert zahlt die referenzielle Prüfung, und Bulk-Loads werden messbar langsamer.
Die pragmatische Aufteilung: Fremdschlüssel im transaktionalen Kern erzwingen, weiche Referenzen (die ID plus genug denormalisierten Kontext, um verständlich zu bleiben, etwa user_email_at_event_time) für unveränderliche Event-Streams verwenden. Die Aufgabe des Event-Logs ist zu beschreiben, was passiert ist, und was passiert ist ändert sich nicht, wenn der Nutzer später gelöscht wird. Genau das macht auch die DSGVO-Löschung handhabbar: die denormalisierten Felder anonymisieren, das Event behalten.
Das Muster hinter allen acht
Achte auf das Thema: Keine dieser Entscheidungen ist an Tag eins schwer richtig zu treffen, und alle sind an Tag vierhundert teuer zu ändern, weil die falsche Wahl bis dahin unter Millionen von Zeilen tragend geworden ist. Schema-Migrationen auf Live-Systemen sind möglich (Expand-Contract macht fast alles machbar), aber jede vermiedene Migration sind Wochen sorgfältiger Entwicklungsarbeit, die du stattdessen ins Produkt stecken kannst.
Wenn du ein SaaS aufbaust und die Datenschicht für die Skalierung entworfen haben willst, die du anpeilst, ist das der Kern unserer Arbeit in der individuellen Softwareentwicklung. Wenn du bereits ein Schema hast und ein schleichendes Misstrauen gegenüber einigen dieser Entscheidungen, sagt dir ein fokussierter Review im Rahmen eines Code- und Architektur-Audits, welche davon wirklich dringend sind und welche warten können.
Fragen zu einer konkreten Schema-Entscheidung? Schreib an hello@wolf-tech.io oder finde uns auf wolf-tech.io. Wir schauen uns gern ein Schema-Diagramm an und sagen dir, was wir ändern würden, bevor es teuer wird.

