Eine Migration von SQL Server nach PostgreSQL scheitert selten am eigentlichen Kopieren der Daten. Sie scheitert an datetime, wo timestamp und timestamptz keine Geschmacksfrage sind, an bit, das kein boolean ist, und an money, das man in PostgreSQL besser gar nicht erst anfasst. Das Datentyp-Mapping SQL Server PostgreSQL entscheidet, ob die Daten sauber ankommen — oder still verfälscht werden, ohne dass eine einzige Fehlermeldung erscheint.
Die gute Nachricht: Die meisten Typen konvertieren eins zu eins. Die schlechte: Genau die paar, die es nicht tun, sind die, die wehtun.
Das Wichtigste vorab:
- Was sauber konvertiert: Ganzzahlen,
numeric/decimal,varchar,date— die einfache Hälfte. - Was kippt:
datetime,bit,money,uniqueidentifier,nvarcharundtinyinthaben keine 1:1-Entsprechung. - Der unsichtbare Aspekt: Collation und Groß-/Kleinschreibung verhalten sich in PostgreSQL anders als im SQL-Server-Standard.
- Was Tooling abnimmt:
pgloadermappt die Standardfälle automatisch — die Edge-Cases bleiben Handarbeit.
Voraussetzung: SQL Server 2017+ als Quelle, PostgreSQL 14+ als Ziel. PostgreSQL-Typen werden bei der ersten Nennung kurz eingeordnet — Vorwissen über Postgres ist nicht nötig, T-SQL-Grundlagen schon.
Inhalt
- Datentyp-Mapping SQL Server → PostgreSQL: die Gesamtübersicht
- Die Typen, die eins zu eins konvertieren
- Die Typen, die kippen
- Collation und Case — der unsichtbare Typ-Aspekt
- Ein konkretes CREATE TABLE — vorher und nachher
- Was pgloader automatisch mappt — und wo Handarbeit bleibt
- FAQ
- Verwandte Artikel
Datentyp-Mapping SQL Server → PostgreSQL: die Gesamtübersicht
Bevor wir in die Fallen gehen, der Gesamtblick. Diese Tabelle ist das Datentyp-Mapping SQL Server → PostgreSQL in Kurzform — die Zeilen im oberen Block sind unkritisch, die im unteren brauchen Aufmerksamkeit.
| SQL Server | PostgreSQL | Hinweis |
|---|---|---|
int · bigint · smallint | integer · bigint · smallint | identisch, gleiche Wertebereiche |
decimal(p, s) · numeric(p, s) | numeric(p, s) | identisch |
varchar(n) · char(n) | varchar(n) · char(n) · text | identisch; text ist in Postgres der Normalfall |
float · real | double precision · real | float (= float(53)) → double precision; float(1–24) → real |
date · time | date · time | identisch (Zeit-Genauigkeit minimal abweichend) |
varbinary(n) · varbinary(max) | bytea | ein Binärtyp statt mehrerer |
xml | xml | identisch als Speichertyp (XQuery/Indizes unterscheiden sich) |
datetime · datetime2 · smalldatetime | timestamp · timestamptz | timestamp ist 1:1; timestamptz ist eine fachliche Entscheidung (Quell-Zeitzone). Wertebereich |
datetimeoffset | timestamptz | Zeitzone wird mitgeführt |
bit | boolean | 0/1 → true/false, kein impliziter Zahl-Cast |
money · smallmoney | numeric(19, 4) · numeric(10, 4) | Postgres-money-Typ meiden |
uniqueidentifier | uuid | NEWID() → gen_random_uuid() |
nvarchar · nchar · ntext | text · varchar | Postgres ist nativ Unicode, kein N-Präfix |
tinyint | smallint | kein 1-Byte-Int, kein unsigned |
rowversion · timestamp (T-SQL) | — | kein Äquivalent; xmin oder App-Logik |
hierarchyid · geography · geometry | PostGIS oder kein Äquivalent | Sonderfall (siehe FAQ) |
sql_variant | — | kein Äquivalent, Umbau nötig |
Die Typen, die eins zu eins konvertieren
Hier gibt es wenig zu erzählen — und das ist der Punkt. Die folgenden Typen lassen sich ohne fachliche Folgen übernehmen:
- Ganzzahlen.
int,bigintundsmallinthaben in PostgreSQL exakt dieselben Namen und exakt dieselben Wertebereiche.integerist 4 Byte,bigint8 Byte,smallint2 Byte — wie drüben. - Dezimalzahlen — mit einer Falle.
decimal(p, s)undnumeric(p, s)sind mit expliziter Präzision in beiden Welten dasselbe (numericist in PostgreSQL der kanonische Name,decimalein Alias). Aufpassen beim weggelassenen Argument: SQL-Server-decimalohne Angabe bedeutetdecimal(18, 0)— also ganzzahlig. PostgreSQL-numericohne Angabe dagegen bedeutet unbeschränkte Präzision mit beliebiger Skala. Ein nacktesdecimal1:1 auf nacktesnumericzu mappen ändert damit das Verhalten — wer die SQL-Server-Semantik erhalten will, schreibt explizitnumeric(18, 0). - Gleitkomma. SQL-Server-
floatentsprichtdouble precision,realbleibtreal. - Texte mit ASCII-Inhalt.
varchar(n)bleibtvarchar(n). In PostgreSQL ist allerdingstext(ohne Längenlimit) der idiomatische Standard — eine Längenbegrenzung setzt man dort nur, wenn sie fachlich eine echte Regel ist, nicht aus Gewohnheit. - Datum (nur Datum).
dateist beidseitig identisch. - Binärdaten. Wo SQL Server zwischen
binary,varbinaryundvarbinary(max)unterscheidet, kennt PostgreSQL nurbytea. Inhaltlich verlustfrei.
Diese Hälfte erledigt jeder Konverter ohne Nachdenken. Spannend wird die andere.
Die Typen, die kippen
datetime → timestamp / timestamptz. Der eigentliche Punkt ist nicht der Wert, sondern die Zeitzone. Das technische 1:1-Mapping ist datetime/datetime2 → timestamp (zeitzonen-naiv → zeitzonen-naiv) — der Wert wandert dabei unverändert. PostgreSQL bietet zusätzlich timestamptz, das einen absoluten Zeitpunkt speichert. Der Wechsel dorthin ist aber keine reine Typkonvertierung, sondern eine fachliche Entscheidung: timestamptz interpretiert den naiven Wert relativ zu einer Zeitzone — und woher die Quelldaten ihre Zeitzone hatten (UTC? Serverzeit? Berlin?), weiß die Datenbank nicht. Nur wer diese Frage beantworten kann, sollte auf timestamptz gehen; sonst bleibt timestamp die sichere Wahl. Zweiter Punkt: der Wertebereich — datetime beginnt erst 1753, PostgreSQL-timestamp reicht weit darüber hinaus (selten praxisrelevant, aber gut zu wissen).
bit → boolean. SQL Server bildet Ja/Nein als bit ab (0, 1 oder NULL). PostgreSQL hat dafür den echten Typ boolean (true/false/NULL). Die Spaltenkonvertierung selbst ist unkritisch — pgloader erledigt sie korrekt. Die Falle steckt in der portierten Logik: In T-SQL ist WHERE is_active = 1 üblich. In PostgreSQL ist is_active bereits boolean, und der Vergleich mit einer Ganzzahl ist ein Fehler (operator does not exist: boolean = integer); ebenso lässt sich eine Ganzzahl nicht implizit als Wahrheitswert verwenden (WHERE int_spalte ist ein Fehler). Korrekt ist WHERE is_active bzw. WHERE is_active = true. Ein expliziter Cast funktioniert dagegen problemlos: 1::boolean ergibt true, 0::boolean ergibt false. Die Stolperfallen sind also die Stored Procedures und Abfragen, die bit-Spalten wie Zahlen behandelt haben (dazu der eigene Artikel zur Code-Portierung).
money → numeric(19, 4). PostgreSQL hat zwar einen Typ namens money — aber den sollte man nicht verwenden. Er ist von der lc_monetary-Locale des Servers abhängig, was Ein- und Ausgabe und damit auch Migrationen fragil macht, und für Berechnungen ist er unhandlich. Die saubere Entsprechung ist numeric mit fester Skala: money → numeric(19, 4), smallmoney → numeric(10, 4). So bleiben Beträge exakt und engine-unabhängig.
uniqueidentifier → uuid. PostgreSQL hat einen nativen uuid-Typ. Der Standardwert wandert mit: DEFAULT NEWID() wird zu DEFAULT gen_random_uuid() — diese Funktion ist seit PostgreSQL 13 im Core, eine Extension ist nicht nötig. Für NEWSEQUENTIALID() (fortlaufende GUIDs) gibt es kein direktes Gegenstück; hier muss man die Strategie bewusst neu wählen.
nvarchar → text / varchar. Das ist oft eine Erleichterung. PostgreSQL-Datenbanken laufen üblicherweise in UTF-8 — der gesamte Text ist also nativ Unicode. Es gibt keinen getrennten „nationalen“ Zeichentyp und kein N'…'-Präfix für Literale. nvarchar(100) wird zu varchar(100) oder schlicht text, nvarchar(max) zu text. Das N vor Strings entfällt komplett. Sonderfall JSON: Hält eine nvarchar(max)-Spalte in Wahrheit JSON (in SQL Server bis 2022 ohne eigenen Typ, als nvarchar(max) abgelegt), ist in PostgreSQL nicht text, sondern jsonb die bessere Wahl — damit kommen Indizierung, Validierung und JSON-Operatoren dazu.
tinyint → smallint. Hier verliert man eine Eigenschaft. SQL-Server-tinyint ist 1 Byte und vorzeichenlos (0–255). PostgreSQL kennt weder einen 1-Byte-Integer noch unsigned. Die Entsprechung ist smallint (2 Byte, −32 768 bis 32 767). Wer die ursprüngliche Semantik erhalten will, ergänzt eine Prüfung: CHECK (spalte BETWEEN 0 AND 255).
rowversion / timestamp → kein Äquivalent (und eine Namensfalle). Aufgepasst: Das SQL-Server-Schlüsselwort timestamp ist ein Synonym für rowversion — ein automatisch hochzählender 8-Byte-Binärwert für die Nebenläufigkeitskontrolle, kein Datum. Wer es blind auf den PostgreSQL-timestamp (der ein Datum/Zeit-Typ ist) mappt, baut einen kapitalen Fehler ein. Ein echtes Gegenstück gibt es nicht; für optimistisches Sperren bietet sich die System-Spalte xmin an oder eine eigene Versionsspalte.
Die Spalten-Defaults und
IDENTITYaus dem Beispiel unten sind streng genommen ein Schema-Thema, kein Typ-Thema — wieIDENTITYzuGENERATED/Sequenzen wird, vertieft der eigene Artikel zur Schema-Migration.
Collation und Case — der unsichtbare Typ-Aspekt
Ein Aspekt, der kein eigener Datentyp ist und trotzdem zur Typ-Migration gehört: das Sortier- und Vergleichsverhalten. SQL Server wird in vielen Installationen mit einer case-insensitiven Standard-Collation betrieben (…_CI_…) — 'Müller' = 'müller' ist dort wahr. PostgreSQL vergleicht Text standardmäßig case-sensitiv und exakt nach Byte/Locale. Dieselbe Abfrage, die in SQL Server eine Zeile fand, findet in PostgreSQL plötzlich keine mehr.
Das ist kein Konvertierungsfehler, sondern eine bewusst zu treffende Entscheidung. Wer case-insensitive Spalten braucht, hat mehrere Wege: eine non-deterministische ICU-Collation (ab PostgreSQL 12) direkt auf der Spalte, die Extension citext (ein case-insensitiver Texttyp) oder explizite LOWER(...)-Vergleiche bzw. ein passendes COLLATE in den betroffenen Abfragen. Eng verwandt ist die Frage, wie Bezeichner (Tabellen- und Spaltennamen) groß-/kleinschreibungsabhängig behandelt werden — das ist ein eigenes Thema und im Schwester-Artikel zur Case-Sensitivity ausführlich behandelt.
Ein konkretes CREATE TABLE — vorher und nachher
Damit das Mapping greifbar wird, eine Tabelle, die alle kritischen Typen 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: ,customer_guid uniqueidentifier NOT NULL DEFAULT NEWID()
5: ,full_name nvarchar(100) NOT NULL
6: ,is_active bit NOT NULL DEFAULT 1
7: ,credit_limit money NULL
8: ,rating tinyint NULL
9: ,created_at datetime NOT NULL DEFAULT GETDATE()
10: ,CONSTRAINT pk_customer PRIMARY KEY (customer_id)
11: );
Und dasselbe als PostgreSQL-Ziel:
1: CREATE TABLE customer
2: (
3: customer_id integer GENERATED BY DEFAULT AS IDENTITY
4: ,customer_guid uuid NOT NULL DEFAULT gen_random_uuid()
5: ,full_name text NOT NULL
6: ,is_active boolean NOT NULL DEFAULT true
7: ,credit_limit numeric(19, 4)
8: ,rating smallint CHECK (rating BETWEEN 0 AND 255)
9: ,created_at timestamptz NOT NULL DEFAULT now()
10: ,CONSTRAINT pk_customer PRIMARY KEY (customer_id)
11: );
Sieben Spalten, sieben Entscheidungen:
- Zeile 3:
int IDENTITY(1, 1)→integer GENERATED BY DEFAULT AS IDENTITY— die Auto-Wert-Spalte wird auf den SQL-Standard-Identitätsmechanismus umgestellt. - Zeile 4:
uniqueidentifier→uuidmitgen_random_uuid()stattNEWID(). - Zeile 5:
nvarchar(100)→text. - Zeile 6:
bit→booleanmittruestatt1. - Zeile 7:
money→numeric(19, 4). - Zeile 8:
tinyint→smallintmit erhaltenderCHECK-Bedingung. - Zeile 9:
datetime→timestamptz(hier bewusst, weil reine Audit-Spalte) mitnow()stattGETDATE().
Kein Konverter, der nur die Spaltentypen tauscht, trifft alle diese Entscheidungen automatisch richtig — money und tinyint insbesondere brauchen die menschliche Korrektur.
Was pgloader automatisch mappt — und wo Handarbeit bleibt
Ein verbreitetes freies Werkzeug für diese Migration ist pgloader — es übernimmt Schema und Daten in einem Lauf direkt aus SQL Server nach PostgreSQL und castet dabei die Typen. Es ist nicht das einzige: bcp/COPY, ETL-Strecken und kommerzielle Tools spielen je nach Datenmenge und Downtime mit — der Methodenvergleich ist ein eigenes Thema (dazu folgt ein eigener Artikel). Für die unkritische Hälfte aus dem ersten Abschnitt ist das ein Selbstläufer: Ganzzahlen, numeric, varchar, date landen korrekt, ohne dass man eine Regel schreiben muss. Auch viele Kipp-Fälle deckt pgloader mit Voreinstellungen ab — etwa bit → boolean oder nvarchar → text.
Handarbeit bleibt dort, wo eine fachliche Entscheidung nötig ist, die kein Tool kennen kann:
datetime→timestampodertimestamptzist die Zeitzonen-Entscheidung von oben — ob der Default des Werkzeugs passt, gehört geprüft, nicht blind übernommen.moneywird per Default oft aufnumericgecastet — aber ob die Skala stimmt und ob nicht doch der ungeeignetemoney-Typ entsteht, gehört geprüft.tinyint→smallintverliert die0–255-Semantik; die erhaltendeCHECK-Bedingung setzt kein Konverter von selbst.rowversion/timestamp,sql_variant,hierarchyidund Geo-Typen haben kein Standard-Mapping und müssen einzeln entschieden werden.- Stored Procedures, Trigger und Default-Ausdrücke mit Typ-Bezug (
GETDATE(),NEWID(),bit-Arithmetik) sind ohnehin reine Handarbeit.
Die Faustregel: pgloader nimmt die mechanischen 80 % ab. Die letzten 20 % sind genau die Typen, die kippen — und die kosten das Nachdenken, um das es in diesem Artikel geht.
FAQ
datetime einfach timestamp? Ja — und die Werte wandern verlustfrei, weil datetime/datetime2 (zeitzonen-naiv) direkt auf das ebenfalls naive timestamp abbilden. Was nicht automatisch passieren sollte, ist der Sprung auf timestamptz: das ist eine fachliche Entscheidung, keine Typkonvertierung, weil timestamptz eine Quell-Zeitzone unterstellt, die in den naiven Daten gar nicht steht. timestamp ist die sichere Default-Wahl; timestamptz nur, wenn die Zeitzone der Quelldaten bekannt ist.
money-Typ verwenden? Weil der PostgreSQL-money-Typ von der lc_monetary-Locale des Servers abhängt, was Ein-/Ausgabe und Migrationen fragil macht, und weil Berechnungen damit umständlich sind. Die robuste, engine-unabhängige Entsprechung für SQL-Server-money ist numeric(19, 4), für smallmoney numeric(10, 4).
nvarchar(max)? nvarchar(max) wird zu text. PostgreSQL-Datenbanken sind in der Regel UTF-8 und damit nativ Unicode — es gibt keinen getrennten „nationalen“ Zeichentyp und kein N'…'-Präfix. Auch nvarchar(100) wird einfach zu varchar(100) oder text; das N vor Stringliteralen entfällt.
tinyint in PostgreSQL? Nein. PostgreSQL kennt keinen 1-Byte-Integer und kein unsigned. Die Entsprechung für tinyint (0–255) ist smallint (−32 768 bis 32 767). Um die ursprüngliche Wertebereichs-Semantik zu erhalten, ergänzt man eine Prüfung: CHECK (spalte BETWEEN 0 AND 255).
uniqueidentifier-Standardwerte? Der Typ wird zu uuid, und der Default NEWID() wird zu gen_random_uuid() — diese Funktion ist seit PostgreSQL 13 im Core, ohne zusätzliche Extension. Für NEWSEQUENTIALID() (aufsteigende GUIDs) gibt es kein direktes Gegenstück; die Strategie ist hier bewusst neu zu wählen.
Verwandte Artikel
Dieser Artikel ist Teil einer Serie zur Migration von SQL Server nach PostgreSQL. Die übrigen Teile (Überblick und die anderen Phasen) folgen als eigene Artikel:
- Überblick: Datenmigration SQL Server nach PostgreSQL — der vollständige Leitfaden (folgt)
- Schema & DDL: Schema-Migration —
IDENTITY, Constraints, Defaults, Sequenzen (folgt) - Datentransfer: Daten transferieren — bcp, COPY, pgloader, ETL (folgt)
- Code-Portierung: T-SQL nach PL/pgSQL portieren (folgt)
- Verifikation: Migration verifizieren — Datenqualität und Zeilen-Abgleich (folgt)
Zur Vertiefung der Typ-Konvertierung selbst: