Schema-Migration SQL Server → PostgreSQL — Identity, Constraints, Defaults, Sequenzen

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: CHECKUNIQUE und FOREIGN KEY ziehen 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 … STOREDdbo → public und 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

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-ObjektPostgreSQL-PendantAufmerksamkeit
PRIMARY KEY · UNIQUE · CHECK · FOREIGN KEYgleichnamiggering — 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()-Defaultsnow() · gen_random_uuid()mittel — Ausdruck übersetzen
Clustered Index (PK physisch sortiert)Heap + B-Tree-Indexhoch — Konzept-Bruch
computed columnGENERATED ALWAYS AS (…) STOREDmittel — STORED (ab PG 18 auch virtuell)
filegrouptablespace (meist streichen)gering
Schema dbopublic oder benanntes Schemagering — 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 IDENTITY verbietet explizite Werte — ein INSERT, der die Spalte selbst füllt, scheitert (es sei denn mit OVERRIDING 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 IDENTITY erlaubt explizite Werte und überlässt den Automatismus nur den INSERTs, 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 die RETURNING-Klausel direkt am INSERT. Wie das beim Portieren von Prozeduren aussieht, behandelt der Artikel zur Code-Portierung.

Constraints und Defaults

Hier ist die Nachricht überwiegend gut. PRIMARY KEYUNIQUECHECK und FOREIGN KEY haben in PostgreSQL dieselbe Syntax und dieselbe Semantik wie in SQL Server, inklusive der referenziellen Aktionen (ON DELETE CASCADEON 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__1A2B3C oder PK__customer__…. Diese Namen sind beim Portieren die Gelegenheit, auf sprechende Namen umzustellen (df_customer_credit_limitpk_customer). Übernimmt man die generierten Namen, ist das erste spätere ALTER TABLE … DROP CONSTRAINT ein Ratespiel.
  • Defaults haben in PostgreSQL keinen eigenen Namen. In SQL Server ist ein DEFAULT ein 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() (oder current_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 mit INCLUDE haben 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 wie AS (price * quantity) wird zu GENERATED ALWAYS AS (price * quantity) STORED. Bis PostgreSQL 17 — worauf die meisten produktiven Systeme heute laufen — ist STORED (persistiert) die einzige Variante; das nicht-persistierte „virtual“ aus SQL Server (erst bei der Abfrage berechnet) ließ sich nur über eine VIEW nachbilden. 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-filegroups lösen sich in der Migration meist in Luft auf — der Default-Tablespace genügt fast immer. Ein eigener TABLESPACE lohnt nur, wenn wirklich eine eigene Platte/Storage-Klasse dahintersteht.
  • dbo → public oder benanntes Schema. Das Standard-Schema dbo von SQL Server entspricht funktional dem public-Schema von PostgreSQL. Wer Ordnung mag, legt stattdessen benannte Schemas an (appstaging) und setzt den search_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 ist snake_case ohne 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 DEFAULT wegen des Migrations-Loads.
  • Zeile 6: money → numeric(19, 4) (Typ-Detail siehe Datentyp-Artikel); das benannte Default-Constraint df_customer_limit wird zum schlichten Spalten-Default DEFAULT 0.
  • Zeile 7: datetime → timestamptzGETDATE() → now(); auch hier verliert der Default seinen Constraint-Namen.
  • Zeilen 8–11: PRIMARY KEYUNIQUECHECK und FOREIGN KEY ziehen wörtlich um — die dbo.-Qualifizierung der referenzierten Tabelle entfällt (Schema public bzw. 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

Aus 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.

Warum startet meine ID-Vergabe nach dem Import wieder bei 1?

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.

Bekomme ich nach der Migration lückenlose IDs?

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.

Hat PostgreSQL Clustered Indexe?

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.

Was wird aus meinen 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.

Bleibt das Schema dbo erhalten?

Funktional entspricht dbo dem public-Schema von PostgreSQL — man kann also alles nach public legen. Sauberer ist es oft, benannte Schemas (appstaging) 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:

Zum Ziel-Schema-Idiom in PostgreSQL: