Eine Schema-Migration SQL Server → PostgreSQL wirkt erledigt, sobald das CREATE TABLE-Skript ohne Fehler durchläuft. Genau dann beginnt das eigentliche Problem: Die Tabelle steht, die Daten sind drin — und der erste INSERT, der eine neue ID vergeben soll, kollidiert mit einem bestehenden Schlüssel. Der Grund ist kein Tippfehler, sondern ein Mechanismus-Wechsel. Aus IDENTITY wird in PostgreSQL eine Sequenz, und die zählt nach einem Daten-Load mit expliziten Schlüsselwerten immer noch ab 1.
Das Schema ist die Schicht, in der SQL Server und PostgreSQL sich ähnlich genug sehen, um Vertrauen zu erzeugen, und unterschiedlich genug sind, um es zu enttäuschen. Dieser Artikel zeigt, welche DDL-Objekte sauber umziehen und welche eine bewusste Entscheidung verlangen.
Das Wichtigste vorab:
IDENTITY→GENERATED/Sequenz: die Übersetzung ist einfach — der Sequenz-Reset nach dem Daten-Load ist die Falle, die fast jeder einmal übersieht.- Constraints und Defaults:
CHECK,UNIQUEundFOREIGN KEYziehen praktisch 1:1 um; die Auto-Namen von SQL Server (DF__…) sollten dabei verschwinden. - Der fehlende Clustered Index: PostgreSQL-Tabellen sind Heaps — der größte mentale Bruch für SQL-Server-Umsteiger.
- Der Rest:
computed columns→GENERATED … STORED,dbo→publicund die Groß-/Kleinschreibung von Bezeichnern.
Voraussetzung: SQL Server 2017+ als Quelle, PostgreSQL 14+ als Ziel. PostgreSQL-Konstrukte werden bei der ersten Nennung kurz eingeordnet — Postgres-Vorwissen ist nicht nötig, T-SQL-Grundlagen schon. Das reine Spalten-Typ-Mapping (welcher Datentyp wird was) ist ein eigenes Thema und im Schwester-Artikel zum Datentyp-Mapping behandelt; hier geht es um die Schema-Objekte drumherum.
Inhalt
- Schema-Migration SQL Server → PostgreSQL: was sich am Schema ändert
- IDENTITY → GENERATED und der Sequenz-Reset
- Constraints und Defaults
- Der fehlende Clustered Index
- computed columns, Schemas und weitere DDL-Eigenheiten
- Ein konkretes CREATE TABLE — vorher und nachher
- FAQ
- Verwandte Artikel
Schema-Migration SQL Server → PostgreSQL: was sich am Schema ändert
Bevor wir in die Objekte gehen, der Überblick. Die meisten Schema-Bestandteile haben in PostgreSQL ein direktes Gegenstück — die Schema-Migration SQL Server → PostgreSQL scheitert nicht an der Mehrheit, sondern an einer Handvoll Mechanismen, die anders funktionieren, als der Name vermuten lässt.
| SQL-Server-Objekt | PostgreSQL-Pendant | Aufmerksamkeit |
|---|---|---|
PRIMARY KEY · UNIQUE · CHECK · FOREIGN KEY | gleichnamig | gering — fast 1:1 portabel |
IDENTITY(1, 1) | GENERATED … AS IDENTITY (Sequenz) | hoch — Sequenz-Reset nach Load |
benannter DEFAULT-Constraint (DF__…) | Spalten-Default (unbenannt) | mittel — der Name entfällt |
GETDATE() · NEWID()-Defaults | now() · gen_random_uuid() | mittel — Ausdruck übersetzen |
| Clustered Index (PK physisch sortiert) | Heap + B-Tree-Index | hoch — Konzept-Bruch |
computed column | GENERATED ALWAYS AS (…) STORED | mittel — STORED (ab PG 18 auch virtuell) |
filegroup | tablespace (meist streichen) | gering |
Schema dbo | public oder benanntes Schema | gering — Topologie abbilden |
Die Trennlinie verläuft nicht zwischen „funktioniert“ und „funktioniert nicht“, sondern zwischen „kopiert sich von selbst“ und „braucht eine bewusste Entscheidung“. Die folgenden Abschnitte gehen genau die Zeilen mit hoher und mittlerer Aufmerksamkeit durch.
IDENTITY → GENERATED und der Sequenz-Reset
In SQL Server vergibt IDENTITY(1, 1) automatisch fortlaufende Schlüssel. PostgreSQL kennt zwei Wege dorthin, und die Wahl ist relevant.
Der historische Weg ist serial (bzw. bigserial): ein Kürzel, das im Hintergrund eine Sequenz anlegt und sie als Spalten-Default einträgt. Der moderne, seit PostgreSQL 10 verfügbare Weg ist der SQL-Standard GENERATED … AS IDENTITY — und der ist heute vorzuziehen. Er bindet die Sequenz fest an die Spalte (sie verschwindet mit der Tabelle), führt sie sauber im Katalog und vermeidet die Rechte- und Ownership-Fallstricke, die serial mit seiner lose gekoppelten Sequenz mitbringt.
Bleibt die Frage ALWAYS oder BY DEFAULT:
GENERATED ALWAYS AS IDENTITYverbietet explizite Werte — einINSERT, der die Spalte selbst füllt, scheitert (es sei denn mitOVERRIDING SYSTEM VALUE). Sauber für den laufenden Betrieb, aber unpraktisch für den Migrations-Load, der genau die alten Schlüsselwerte mitbringt.GENERATED BY DEFAULT AS IDENTITYerlaubt explizite Werte und überlässt den Automatismus nur denINSERTs, die die Spalte auslassen. Das ist die richtige Wahl für die Migration: Der Load schreibt die bestehenden IDs unverändert, der spätere Betrieb bekommt sie automatisch.
Und damit zur eigentlichen Falle. Ein INSERT mit explizitem Schlüsselwert lässt die Sequenz unberührt — sie zählt nicht mit. Nach einem Load, der die Schlüssel 1 bis 5000 direkt einträgt, steht die Sequenz weiterhin auf ihrem Startwert 1. Der nächste automatische INSERT zieht also 1 — und läuft in eine Primärschlüssel-Kollision. Deshalb gehört nach jedem Load mit expliziten IDs ein Sequenz-Reset auf den höchsten vergebenen Wert:
1: -- Nach dem Load mit expliziten customer_id-Werten den Identitäts-Zähler
2: -- auf den höchsten vergebenen Wert heben:
3: SELECT setval(
4: pg_get_serial_sequence('customer', 'customer_id')
5: ,(SELECT max(customer_id) FROM customer)
6: );
pg_get_serial_sequence ermittelt den internen Namen der zur Spalte gehörenden Sequenz — das spart das Raten des automatisch vergebenen Sequenz-Namens. Der Schritt ist pro Tabelle mit Identitäts-Spalte einmal fällig; vergisst man ihn, ist der Fehler kein stiller, sondern ein lauter beim ersten produktiven INSERT. (Ist die Zieltabelle nach dem Load leer, liefert max(...) NULL — dann setval(..., 1, false), damit die Vergabe sauber bei 1 beginnt.)
Den
SCOPE_IDENTITY()-Reflex aus T-SQL — den gerade vergebenen Schlüssel zurücklesen — ersetzt in PostgreSQL dieRETURNING-Klausel direkt amINSERT. Wie das beim Portieren von Prozeduren aussieht, behandelt der Artikel zur Code-Portierung.
Constraints und Defaults
Hier ist die Nachricht überwiegend gut. PRIMARY KEY, UNIQUE, CHECK und FOREIGN KEY haben in PostgreSQL dieselbe Syntax und dieselbe Semantik wie in SQL Server, inklusive der referenziellen Aktionen (ON DELETE CASCADE, ON UPDATE …). Eine als CONSTRAINT … benannte Bedingung wandert wörtlich mit.
Drei Punkte verdienen Aufmerksamkeit:
- Auto-Namen loswerden. SQL Server vergibt für unbenannte Constraints generierte Namen wie
DF__customer__credi__1A2B3CoderPK__customer__…. Diese Namen sind beim Portieren die Gelegenheit, auf sprechende Namen umzustellen (df_customer_credit_limit,pk_customer). Übernimmt man die generierten Namen, ist das erste spätereALTER TABLE … DROP CONSTRAINTein Ratespiel. - Defaults haben in PostgreSQL keinen eigenen Namen. In SQL Server ist ein
DEFAULTein benanntes Constraint-Objekt; in PostgreSQL ist der Default schlicht eine Eigenschaft der Spalte (ALTER TABLE … ALTER COLUMN … SET DEFAULT …). Der Default-Wert zieht mit, das benannte Default-Constraint als separates Objekt gibt es im Ziel nicht — das ist kein Verlust, nur eine andere Buchführung. - Default-Ausdrücke übersetzen. Die häufigen SQL-Server-Defaults haben direkte Entsprechungen:
GETDATE()→now()(odercurrent_timestamp),GETUTCDATE()→now() AT TIME ZONE 'utc',NEWID()→gen_random_uuid(). Konstante Defaults (0,'',1) ziehen unverändert um. Die Detail-Begründung zu den Typ-bezogenen Defaults steht im Datentyp-Artikel.
Ein Detail in die andere Richtung: PostgreSQL kann Constraints DEFERRABLE INITIALLY DEFERRED setzen — ihre Prüfung wird dann ans Transaktionsende verschoben. SQL Server kennt das nicht. Für zyklische Fremdschlüssel oder Bulk-Umsortierungen ist das ein nützliches Werkzeug, das man bei der Gelegenheit mitdenken kann.
Der fehlende Clustered Index
Hier weicht PostgreSQL am stärksten von dem ab, was man aus SQL Server gewohnt ist. In SQL Server bestimmt der Clustered Index die physische Sortierung der Tabelle: Die Daten liegen in Schlüsselreihenfolge auf der Platte, und der Primärschlüssel ist standardmäßig der Clustered Index. Jede Tabelle hat höchstens einen.
PostgreSQL kennt dieses Konzept nicht. Eine Tabelle ist ein Heap — eine unsortierte Sammlung von Zeilen in der Einfüge-Reihenfolge. Der Primärschlüssel ist ein ganz normaler B-Tree-Index, der in den Heap zeigt; er erzwingt keine physische Ordnung. Es gibt zwar den Befehl CLUSTER table USING index, der die Tabelle einmalig nach einem Index physisch umsortiert — aber er ist keine selbsterhaltende Eigenschaft: Neue Zeilen landen weiter am Heap-Ende, und die Ordnung verfällt, bis man CLUSTER erneut ausführt.
Daraus folgt zweierlei für die Index-Planung:
- Nicht als „braucht man nicht“ abtun. Der Clustered Index hat in SQL Server reale Performance-Effekte (Range-Scans entlang der physischen Ordnung). Fällt er weg, plant man Indizes in PostgreSQL bewusst neu — über die tatsächlichen Abfrage-Muster, nicht über die alte physische Sortierung.
- Was PostgreSQL dafür bietet. Ausdrucks-Indizes (
… (lower(email))) gibt es direkt — in SQL Server geht das nur über den Umweg einer berechneten Spalte. Partielle Indizes (… WHERE status = 'active') entsprechen den gefilterten Indizes von SQL Server, und abdeckende Indizes mitINCLUDEhaben beide Engines (in PostgreSQL seit Version 11 — also keine neue Funktion mehr). Der Wegfall des Clustered Index ist also kein reiner Verlust, sondern ein anderer Werkzeugkasten.
computed columns, Schemas und weitere DDL-Eigenheiten
Die restlichen DDL-Eigenheiten sind einzeln klein, in Summe aber genau die Stellen, an denen ein naiver Konverter strauchelt.
computed column→GENERATED … STORED(ab PostgreSQL 18 auch virtuell). Eine berechnete Spalte wieAS (price * quantity)wird zuGENERATED ALWAYS AS (price * quantity) STORED. Bis PostgreSQL 17 — worauf die meisten produktiven Systeme heute laufen — istSTORED(persistiert) die einzige Variante; das nicht-persistierte „virtual“ aus SQL Server (erst bei der Abfrage berechnet) ließ sich nur über eineVIEWnachbilden. Das junge PostgreSQL 18 hat virtuelle generierte Spalten ergänzt und macht sie sogar zum Default — wer schon dort ist, hat damit beide SQL-Server-Varianten direkt.filegroup→tablespace. SQL-Server-filegroupslösen sich in der Migration meist in Luft auf — der Default-Tablespace genügt fast immer. Ein eigenerTABLESPACElohnt nur, wenn wirklich eine eigene Platte/Storage-Klasse dahintersteht.dbo→publicoder benanntes Schema. Das Standard-Schemadbovon SQL Server entspricht funktional dempublic-Schema von PostgreSQL. Wer Ordnung mag, legt stattdessen benannte Schemas an (app,staging) und setzt densearch_path— das Ziel-Schema-Idiom ist im Artikel zu den Postgres-Tabellen-Konventionen ausgeführt.- Bezeichner-Groß-/Kleinschreibung. PostgreSQL faltet unquotierte Bezeichner auf Kleinschreibung; in Anführungszeichen gesetzte Namen (
"CustomerID") behalten ihre Schreibweise und müssen dann immer so zitiert werden. Die saubere Konsequenz istsnake_caseohne Quoting — warum das keine Geschmacksfrage ist, behandelt der Schwester-Artikel zur Case-Sensitivity von Bezeichnern.
Ein konkretes CREATE TABLE — vorher und nachher
Damit die Übersetzung greifbar wird, eine Tabelle, die IDENTITY, einen money-Default, einen CHECK, ein UNIQUE und einen FOREIGN KEY auf einmal bündelt. Zuerst die Quelle in T-SQL:
1: CREATE TABLE dbo.customer
2: (
3: customer_id int IDENTITY(1, 1) NOT NULL
4: ,region_id int NOT NULL
5: ,email nvarchar(256) NOT NULL
6: ,credit_limit money NOT NULL CONSTRAINT df_customer_limit DEFAULT 0
7: ,created_at datetime NOT NULL CONSTRAINT df_customer_created DEFAULT GETDATE()
8: ,CONSTRAINT pk_customer PRIMARY KEY (customer_id)
9: ,CONSTRAINT uq_customer_email UNIQUE (email)
10: ,CONSTRAINT ck_customer_limit CHECK (credit_limit >= 0)
11: ,CONSTRAINT fk_customer_region FOREIGN KEY (region_id) REFERENCES dbo.region (region_id)
12: );
Und dasselbe als PostgreSQL-Ziel:
1: CREATE TABLE customer
2: (
3: customer_id integer GENERATED BY DEFAULT AS IDENTITY
4: ,region_id integer NOT NULL
5: ,email text NOT NULL
6: ,credit_limit numeric(19, 4) NOT NULL DEFAULT 0
7: ,created_at timestamptz NOT NULL DEFAULT now()
8: ,CONSTRAINT pk_customer PRIMARY KEY (customer_id)
9: ,CONSTRAINT uq_customer_email UNIQUE (email)
10: ,CONSTRAINT ck_customer_limit CHECK (credit_limit >= 0)
11: ,CONSTRAINT fk_customer_region FOREIGN KEY (region_id) REFERENCES region (region_id)
12: );
Was sich Zeile für Zeile geändert hat:
- Zeile 3:
int IDENTITY(1, 1)→integer GENERATED BY DEFAULT AS IDENTITY— der Auto-Schlüssel auf den SQL-Standard-Mechanismus,BY DEFAULTwegen des Migrations-Loads. - Zeile 6:
money→numeric(19, 4)(Typ-Detail siehe Datentyp-Artikel); das benannte Default-Constraintdf_customer_limitwird zum schlichten Spalten-DefaultDEFAULT 0. - Zeile 7:
datetime→timestamptz,GETDATE()→now(); auch hier verliert der Default seinen Constraint-Namen. - Zeilen 8–11:
PRIMARY KEY,UNIQUE,CHECKundFOREIGN KEYziehen wörtlich um — diedbo.-Qualifizierung der referenzierten Tabelle entfällt (Schemapublicbzw.search_path).
Kein Konverter, der nur Spaltentypen tauscht, trifft die Entscheidung zwischen ALWAYS und BY DEFAULT oder erinnert an den Sequenz-Reset — das bleibt Handarbeit.
FAQ
IDENTITY wird in PostgreSQL was? Eine Identitäts-Spalte auf Basis einer Sequenz: GENERATED BY DEFAULT AS IDENTITY (für den Migrations-Load) oder GENERATED ALWAYS AS IDENTITY (für den strengen Betrieb). Der ältere serial-Weg funktioniert noch, aber GENERATED … AS IDENTITY ist der SQL-Standard und seit PostgreSQL 10 die empfohlene Form, weil die Sequenz fest an die Spalte gebunden ist.
Weil ein INSERT mit explizitem Schlüsselwert die Sequenz nicht weiterzählt. Nach einem Load mit den alten IDs steht die Sequenz noch auf dem Startwert. Der nächste automatische INSERT kollidiert deshalb mit einem vorhandenen Schlüssel. Lösung: SELECT setval(pg_get_serial_sequence('tabelle', 'spalte'), (SELECT max(spalte) FROM tabelle)) — einmal pro Tabelle mit Identitäts-Spalte.
Nein — und das war in SQL Server schon so. Sowohl IDENTITY/Sequenzen dort als auch PostgreSQL-Sequenzen reservieren Werte im Voraus (Cache) und geben sie bei einem Crash nicht zurück; auch zurückgerollte Transaktionen verbrauchen ihren Wert. Das Ergebnis sind Lücken, nie doppelte Schlüssel — der Zähler springt im Zweifel vorwärts. Praktisch ist PostgreSQL sogar zahmer: Der Default CACHE 1 reserviert nur einen Wert, während SQL-Server-IDENTITY nach einem unsauberen Neustart um bis zu 1000 springen kann. Wer fortlaufende, lückenlose Nummern braucht (etwa Rechnungsnummern), darf sich weder dort noch hier auf die Identitäts-Spalte verlassen.
Nein. PostgreSQL-Tabellen sind Heaps; der Primärschlüssel ist ein normaler B-Tree-Index, keine physische Sortierung. Der Befehl CLUSTER sortiert eine Tabelle einmalig nach einem Index um, hält die Ordnung aber nicht aufrecht — neue Zeilen landen am Heap-Ende. Indizes werden in PostgreSQL über die Abfrage-Muster geplant, nicht über die physische Reihenfolge.
filegroups? Meist nichts — sie entfallen. Der Default-Tablespace von PostgreSQL genügt fast immer. Nur wenn hinter einer filegroup tatsächlich eine eigene Platte oder Storage-Klasse steht, bildet man sie über einen benannten TABLESPACE nach.
dbo erhalten? Funktional entspricht dbo dem public-Schema von PostgreSQL — man kann also alles nach public legen. Sauberer ist es oft, benannte Schemas (app, staging) anzulegen und den search_path zu setzen. Eine 1:1-Beibehaltung des Namens dbo ist möglich, aber unüblich.
Verwandte Artikel
Dieser Artikel ist Teil einer Serie zur Migration von SQL Server nach PostgreSQL. Die übrigen Teile folgen als eigene Artikel:
- Überblick: Datenmigration SQL Server nach PostgreSQL — der vollständige Leitfaden (folgt)
- Datentypen: Datentyp-Mapping SQL Server → PostgreSQL — was sauber konvertiert und was kippt
- Datentransfer: Daten transferieren: bcp, COPY, pgloader, ETL — welche Methode wann
- Code-Portierung: T-SQL nach PL/pgSQL portieren (folgt)
- Verifikation: Migration verifizieren — Datenqualität und Zeilen-Abgleich (folgt)
Zum Ziel-Schema-Idiom in PostgreSQL: