Die Daten sind drüben, das Schema steht — und dann liegen da 200 Stored Procedures, die kein Werkzeug für dich übersetzt. pgloader migriert Tabellen und Daten, aber die Logik in Prozeduren, Funktionen und Triggern bleibt liegen. Das ist die Phase, die wirklich Arbeit macht: T-SQL nach PL/pgSQL portieren, Zeile für Zeile, mit Verständnis statt Suchen-und-Ersetzen.
Die gute Nachricht: Der Großteil ist mechanisch. Ein paar Dutzend Idiome (ISNULL→COALESCE, TOP→LIMIT, @variable→l_variable) decken die Mehrheit ab, und die strukturellen Unterschiede sind schnell erfasst. Dieser Artikel ist das Nachschlagewerk dafür — plus die ehrliche Liste der Stellen, die Handarbeit und ein Auge fürs Detail verlangen.
Das Wichtigste vorab:
- Struktur zuerst:
CREATE FUNCTION/PROCEDUREmit$$-Quoting, derDECLARE-Block wandert an den Anfang, das@vor Variablen entfällt. - Die Idiom-Tabelle übersetzt die häufigsten Funktionen und Konstrukte 1:1 — das mechanische 80 %.
- Fehlerbehandlung:
TRY/CATCHwirdBEGIN … EXCEPTION WHEN … END,THROWwirdRAISE— aber oft brauchst du es gar nicht, weil PL/pgSQL Fehler von selbst weiterreicht. - Dynamisches SQL ist die Sicherheits-Stelle:
sp_executesqlwirdEXECUTE format(...)mit%I/%L.
Voraussetzung: SQL Server als Quelle, PostgreSQL 14+ als Ziel. T-SQL-Konstrukte (TRY/CATCH, @variable, TOP, ISNULL, sp_executesql, Temp-Tables #t) werden vorausgesetzt; die PL/pgSQL-Pendants werden gezeigt. Wie sauberer PL/pgSQL-Code aussieht (Volatilität, RETURNS, Namens-Konventionen) ist ein eigenes Thema — die Konventions-Artikel zu Prozeduren und Funktionen sind verlinkt; hier geht es um die reine Übersetzung.
Inhalt
- Von T-SQL nach PL/pgSQL: die strukturellen Unterschiede
- Die Idiom-Übersetzungstabelle
- Fehlerbehandlung: TRY/CATCH wird EXCEPTION
- Dynamisches SQL sicher portieren
- Vorher und nachher: die ganze Prozedur
- Was kein Tool abnimmt
- FAQ
- Verwandte Artikel
Von T-SQL nach PL/pgSQL: die strukturellen Unterschiede
Bevor die einzelnen Funktionen dran sind, der Rahmen. Eine T-SQL-Prozedur und eine PL/pgSQL-Routine sehen auf den ersten Blick ähnlich aus — BEGIN, END, DECLARE —, aber vier Dinge sind anders, und sie betreffen jede portierte Routine.
So sieht eine T-SQL-Prozedur im Skelett aus:
1: CREATE PROCEDURE dbo.usp_name
2: @p_param int
3: AS
4: BEGIN
5: DECLARE @l_var int;
6: -- Logik
7: END;
Und dasselbe als PL/pgSQL:
1: CREATE OR REPLACE FUNCTION fn_name
2: (
3: IN p_param integer
4: )
5: RETURNS integer
6: LANGUAGE plpgsql
7: AS $function$
8: DECLARE
9: l_var integer;
10: BEGIN
11: -- Logik
12: RETURN l_var;
13: END;
14: $function$;
Die vier Unterschiede:
- Der Body steckt in Dollar-Quotes. Der Funktionsrumpf wird als Dollar-Quoted String an
CREATE FUNCTIONübergeben und beim Anlegen vom PL/pgSQL-Handler kompiliert — nicht etwa zur Laufzeit als Text ausgeführt. Deshalb steht der gesamte Rumpf zwischen$function$ … $function$(bzw.$procedure$für Prozeduren); das Dollar-Quoting spart das Escapen von Hochkommas im Code. DECLAREsteht am Anfang, einmal. T-SQL erlaubtDECLAREan beliebiger Stelle; PL/pgSQL sammelt alle lokalen Variablen in einemDECLARE-Block direkt vorBEGIN. Das@vor Variablen und Parametern entfällt komplett — aus@l_varwirdl_var, aus@p_paramwirdp_param.PROCEDUREoderFUNCTION? Hier liegt die erste echte Entscheidung. Postgres hat beides: EineFUNCTIONgibt einen Wert (oder eine Tabelle) zurück und ist der Normalfall für alles, was ein Ergebnis liefert. EinePROCEDURE(seit PostgreSQL 11) gibt nichts zurück, darf dafür aberCOMMIT/ROLLBACKausführen — sie ist für mehrschrittige Batch-Abläufe da. Eine T-SQL-Prozedur, die perSELECTeinen Wert zurückgibt, wird in Postgres deshalb meist eineFUNCTION, keinePROCEDURE— und eine, die ein ganzes Resultset liefert, eineFUNCTIONmitRETURNS TABLE/SETOF(Details im Tabellen-Rückgabe-Artikel unten).- Namen ziehen direkt in den Zielstil um. Beim Portieren landet der Code gleich in
snake_case— die SQL-Server-PascalCase-Namen (usp_AddCustomer) schleppt man nicht mit. Diefn_-/sp_-Präfixe in den Beispielen sind die Konvention dieses Blogs, kein PostgreSQL-Gebot; viele Projekte verzichten bewusst auf Präfixe und nutzen nursnake_case. So oder so gilt: Der richtige Moment für die Umstellung ist jetzt, nicht später.
SET NOCOUNT ON und Ähnliches haben kein Gegenstück und entfallen ersatzlos — Postgres schickt keine „rows affected“-Zwischenmeldungen, die man unterdrücken müsste.
Die Idiom-Übersetzungstabelle
Die folgende Tabelle ist das eigentliche Nachschlagewerk — die häufigsten T-SQL-Konstrukte und ihr PL/pgSQL-Pendant:
| T-SQL | PL/pgSQL | Hinweis |
|---|---|---|
ISNULL(x, y) | COALESCE(x, y) | COALESCE ist SQL-Standard und n-ary; anderer Rückgabetyp als ISNULL — siehe unten |
GETDATE() | now() / current_timestamp | now() = current_timestamp, aber Transaktions-Startzeit (konstant je Transaktion); für die Uhrzeit beim Aufruf clock_timestamp() |
GETUTCDATE() | now() AT TIME ZONE 'utc' | UTC-Zeit als timestamp without time zone (zeitzonenlos) |
DATEADD(day, 1, @d) | @d + interval '1 day' | date + int zählt Tage; timestamp + int ist ein Fehler — siehe unten |
DATEDIFF(day, a, b) | (b::date - a::date) | für day ≈ Datumsgrenzen; bei month/year zählt DATEDIFF Grenzen — dafür age()/extract, keine simple Subtraktion |
LEN(s) | length(s) | beide zählen Zeichen; LEN ignoriert nachgestellte Leerzeichen, length nicht — bei Bedarf length(rtrim(s)) |
DATALENGTH(s) | octet_length(s) | Bytes statt Zeichen; SQL-Server-nvarchar ist UTF-16 (2 Byte/Zeichen), Postgres UTF-8 — die Byte-Zahlen weichen bei Nicht-ASCII ab |
SUBSTRING(s, 1, 3) | substring(s, 1, 3) | beide 1-basiert |
CHARINDEX('x', s) | position('x' IN s) | beide 0, wenn nicht gefunden |
a + b (Strings) | a || b | + ist in Postgres nur arithmetisch; für NULL-tolerante Verkettung concat(a, b) |
TOP (n) | LIMIT n | steht in Postgres am Ende des SELECT |
IIF(c, a, b) | CASE WHEN c THEN a ELSE b END | – |
@variable | l_variable (im DECLARE) | kein @, Deklaration am Block-Anfang |
@p … OUTPUT (Parameter) | OUT p / INOUT p | Rückgabe über benannte OUT-Parameter statt OUTPUT |
EXEC sp · SELECT fn() (Ergebnis verwerfen) | CALL sp() · PERFORM fn() | ein nacktes SELECT ohne INTO ist im PL/pgSQL ein Fehler — PERFORM verwirft das Ergebnis |
#temp | CREATE TEMP TABLE oder CTE | oft reicht eine CTE statt einer echten Temp-Table |
OUTPUT inserted.id | RETURNING id | direkt am INSERT/UPDATE/DELETE |
SCOPE_IDENTITY() | RETURNING … INTO | der saubere Weg; currval/lastval nur mit Vorsicht |
sp_executesql | EXECUTE format(…) USING … | siehe eigener Abschnitt |
TRY/CATCH | BEGIN … EXCEPTION WHEN … END | siehe eigener Abschnitt |
THROW / RAISERROR | RAISE EXCEPTION … USING … | siehe eigener Abschnitt |
Drei Fallen aus der Tabelle verdienen einen Satz mehr. ISNULL vs. COALESCE: Die beiden sind schon in SQL Server nicht typgleich. ISNULL(x, y) übernimmt den Datentyp des ersten Arguments und kürzt den zweiten Wert darauf — ISNULL(@v varchar(3), 'ABCDEF') liefert 'ABC'. COALESCE folgt dagegen der Typ-Präzedenz über alle Argumente und kürzt nicht ('ABCDEF'). Das nach Postgres portierte COALESCE verhält sich wie das SQL-Server-COALESCE (keine Kürzung, hier gegen Postgres 16 belegt) — verlässt sich der Quellcode auf die ISNULL-Kürzung, ändert die Portierung das Ergebnis. LEN vs. length: Wer in T-SQL eine Längenprüfung auf einem char(n)-Feld macht, verlässt sich oft unbewusst darauf, dass LEN die Leerzeichen am Ende wegrechnet — die naive Portierung nach length ändert dann das Ergebnis. + vs. ||: Ein versehentlich übernommenes + zwischen zwei Strings ist in Postgres kein Tippfehler, sondern ein Typfehler oder — bei Zahlen-Strings — ein stilles Falschergebnis.
Dahinter steht eine grundsätzliche Haltung, die jede portierte Berechnung betrifft: PostgreSQL konvertiert in Ausdrücken viel weniger implizit als SQL Server. SQL Server wandelt großzügig zwischen Zahl und String ('1' + 1 ergibt 2, eine int-Spalte gegen eine varchar-Spalte wird stillschweigend angeglichen). Postgres tut das nur für untypisierte Literale und wirft bei typisierten Werten einen Fehler — '1'::text + 1 ist operator does not exist: text + integer.
Zwei Konsequenzen fürs Portieren: Datums-Arithmetik braucht ein interval — aus GETDATE() + 1 wird now() + interval '1 day', nicht now() + 1 (das ist in Postgres ein Fehler). Und String-/Zahl-Mischungen müssen explizit gecastet werden (::numeric, ::int).
Die Ganzzahl-Division verhält sich dagegen in beiden Systemen gleich (5 / 2 = 2) — hier ändert sich beim Portieren nichts. Das vollständige Spalten-Typ-Mapping behandelt der Datentyp-Artikel.
Fehlerbehandlung: TRY/CATCH wird EXCEPTION
Die Übersetzung ist auf den ersten Blick mechanisch: Aus dem BEGIN TRY … END TRY BEGIN CATCH … END CATCH von T-SQL wird ein BEGIN … EXCEPTION WHEN … END-Block in PL/pgSQL. Aus THROW und RAISERROR wird RAISE.
Der wichtigere Punkt ist aber: Oft brauchst du den Block gar nicht. In T-SQL ist TRY/CATCH häufig nur dazu da, einen Fehler abzufangen und sofort wieder zu werfen (BEGIN CATCH THROW; END CATCH). PL/pgSQL reicht Fehler von selbst nach oben weiter — ein Block, der nur abfängt und neu wirft, ist überflüssig und kann ersatzlos verschwinden. Einen EXCEPTION-Block setzt man nur dort, wo man den Fehler wirklich behandelt.
So sieht eine echte Behandlung aus — eine doppelte E-Mail abfangen, statt die Prozedur abbrechen zu lassen:
1: CREATE OR REPLACE FUNCTION fn_try_add_customer
2: (
3: IN p_email text
4: )
5: RETURNS integer
6: LANGUAGE plpgsql
7: AS $function$
8: DECLARE
9: l_new_id integer;
10: BEGIN
11:
12: INSERT INTO customer (email)
13: VALUES (p_email)
14: RETURNING customer_id INTO l_new_id;
15:
16: RETURN l_new_id;
17:
18: EXCEPTION
19: WHEN unique_violation THEN
20: RAISE NOTICE 'email % already exists', p_email;
21: RETURN NULL;
22: END;
23: $function$;
Drei Dinge, die beim Portieren der Fehlerbehandlung auffallen:
- Fehler werden über benannte Codes gefangen, nicht über Nummern. Statt der SQL-Server-Fehlernummer (
2627für eine Schlüsselverletzung) fängt PL/pgSQL über sprechendecondition nameswieunique_violationoderforeign_key_violation.WHEN OTHERSist das Pendant zum allgemeinenCATCH. THROW 50001, 'text', 1wirdRAISE EXCEPTION 'text'. Die Meldung steht direkt amRAISE. DieUSING ERRCODE = '…'-Klausel ist optional — sie lohnt nur, wenn der Fehler gezielt über einenSQLSTATEgefangen werden soll; im Normalfall genügt das nackteRAISE EXCEPTION 'text'. Für reine Hinweise gibt esRAISE NOTICE/RAISE WARNING— das Pendant zuRAISERRORmit niedrigem Schweregrad.ERROR_MESSAGE()/ERROR_NUMBER()werdenSQLERRM/SQLSTATE. Innerhalb desEXCEPTION-Blocks liefern diese beiden Variablen Text und Code des gefangenen Fehlers.
Eine Performance-Notiz, die man leicht übersieht: Jeder EXCEPTION-Block öffnet intern eine Subtransaktion (einen Savepoint). Das kostet pro Aufruf etwas — in einer engen Schleife mit Millionen Durchläufen ist ein EXCEPTION-Block also nicht gratis. Das passt zur Denkweise aus der Typ-Konvertierung: Wo immer möglich, ist die fehlertolerante Operation (eine sichere Umwandlung, ein ON CONFLICT) der sauberere Weg als das Abfangen einer geworfenen Exception.
Dynamisches SQL sicher portieren
Hier entscheidet sich nicht nur die Syntax, sondern die Sicherheit. T-SQL baut dynamisches SQL mit sp_executesql und parametrisiert über @params. PL/pgSQL nutzt EXECUTE zusammen mit format() — und format() hat zwei Platzhalter, die genau die Injektions-Lücke schließen, die String-Verkettung aufreißt:
%Iquotet einen Bezeichner (Tabellen-, Spaltenname) korrekt und sicher.%Lquotet ein Literal; alternativ — und besser für Werte — bindet man Parameter überUSINGund referenziert sie als$1,$2.
1: CREATE OR REPLACE FUNCTION fn_count_rows
2: (
3: IN p_schema_name text
4: ,IN p_table_name text
5: ,IN p_min_id integer
6: )
7: RETURNS bigint
8: LANGUAGE plpgsql
9: AS $function$
10: DECLARE
11: l_count bigint;
12: BEGIN
13:
14: EXECUTE format($sql$SELECT count(*)
15: FROM %I.%I
16: WHERE customer_id >= $1
17: $sql$
18: ,p_schema_name
19: ,p_table_name
20: )
21: INTO l_count
22: USING p_min_id;
23:
24: RETURN l_count;
25: END;
26: $function$;
Das Muster: Bezeichner über %I in den SQL-String einsetzen (zur Bau-Zeit), Werte über $1 und USING binden (zur Ausführungs-Zeit). Niemals einen Eingabewert per || in den String kleben — das ist die klassische SQL-Injektions-Stelle. Die Vertrauensgrenze ist damit klar gezogen: Strukturen (%I) sind kontrolliert, Werte (USING) sind typsicher gebunden. Dasselbe format()-Muster trägt auch das Datenqualitäts-Framework, das generische Prüfregeln über dynamisches SQL ausführt — der Querverweis steht unten.
Vorher und nachher: die ganze Prozedur
Zum Zusammenführen das durchgehende Beispiel: eine T-SQL-Prozedur, die einen Kunden anlegt, die E-Mail prüft und die neue ID zurückgibt. Zuerst die Quelle:
1: CREATE PROCEDURE dbo.usp_add_customer
2: @p_email nvarchar(256)
3: ,@p_credit_limit money = 0
4: AS
5: BEGIN
6: SET NOCOUNT ON;
7: DECLARE @l_new_id int;
8:
9: IF @p_email IS NULL OR LEN(@p_email) = 0
10: THROW 50001, 'email must not be empty', 1;
11:
12: INSERT INTO dbo.customer (email, credit_limit)
13: VALUES (@p_email, ISNULL(@p_credit_limit, 0));
14:
15: SET @l_new_id = SCOPE_IDENTITY();
16:
17: SELECT @l_new_id AS new_customer_id;
18: END;
Und das PL/pgSQL-Ziel — weil die Routine einen Wert zurückgibt, wird sie eine FUNCTION:
1: CREATE OR REPLACE FUNCTION fn_add_customer
2: (
3: IN p_email text
4: ,IN p_credit_limit numeric(19, 4) DEFAULT 0
5: )
6: RETURNS integer
7: LANGUAGE plpgsql
8: AS $function$
9: DECLARE
10: l_new_id integer;
11: BEGIN
12:
13: IF p_email IS NULL OR length(p_email) = 0 THEN
14: RAISE EXCEPTION 'email must not be empty'
15: USING ERRCODE = 'check_violation';
16: END IF;
17:
18: INSERT INTO customer (email, credit_limit)
19: VALUES (p_email, COALESCE(p_credit_limit, 0))
20: RETURNING customer_id INTO l_new_id;
21:
22: RETURN l_new_id;
23: END;
24: $function$;
Was sich Zeile für Zeile geändert hat:
- Signatur:
@p_email nvarchar(256)→p_email text,@p_credit_limit money = 0→p_credit_limit numeric(19, 4) DEFAULT 0(so wirdmoneybei Migrationen üblicherweise ersetzt; das Typ-Mapping behandelt der Datentyp-Artikel). Das@und die Klammer-Syntax folgen der PL/pgSQL-Form. IF … LEN(…) = 0→IF … length(…) = 0 THEN … END IF;— dasTHENund dasEND IF;sind in PL/pgSQL Pflicht.THROW→RAISE EXCEPTION … USING ERRCODE = ….INSERT+SCOPE_IDENTITY()→INSERT … RETURNING customer_id INTO l_new_id— ein Schritt statt zwei, ohne dieSCOPE_IDENTITY()-Falle.SELECT @l_new_id→RETURN l_new_id;— die Funktion gibt den Wert direkt zurück, statt ihn als einzeiliges Resultset zu schicken.
Was kein Tool abnimmt
Die mechanische Übersetzung deckt die Mehrheit ab. Die „letzten 20 %“ sind die Stellen, an denen ein automatischer Konverter strauchelt oder schlicht aufgibt — und an denen sich entscheidet, ob der portierte Code auch wirklich dasselbe tut:
- Cursor-Logik. T-SQL-Cursor lassen sich oft durch eine mengenbasierte Anweisung ersetzen — fast immer die bessere Lösung. Wo ein Cursor bleiben muss, wird er zur
FOR … IN … LOOP. Das ist Umdenken, kein Suchen-und-Ersetzen. MERGE-Feinheiten. Postgres kenntMERGE(seit Version 15) undINSERT … ON CONFLICT.ON CONFLICTersetztMERGEaber nicht vollständig — es greift nur bei Unique-/Exclusion-Konflikten; für allgemeinere Upsert-Logik bleibtMERGE. Die Wahl und die exakte Semantik (was bei Mehrfach-Treffern passiert) muss man pro Statement prüfen.- Trigger-Funktionen. Ein T-SQL-Trigger wird in Postgres zu zwei Objekten: einer Trigger-Funktion (die Logik) und dem Trigger selbst (die Verdrahtung an die Tabelle).
inserted/deletedwerden zuNEW/OLD. - Transaktions- und Fehler-Semantik — der größte konzeptionelle Unterschied. Schlägt eine Routine in Postgres mit einem unbehandelten Fehler fehl, wird die ganze Transaktion zurückgerollt — auch die Anweisungen, die vor dem Fehler schon liefen (gegen Postgres 16 belegt: zwei
INSERTs vor einemRAISE→ 0 Zeilen übrig). SQL Server macht das nicht zwingend: Im Default (XACT_ABORT OFF) rollt ein Laufzeitfehler oft nur die fehlerhafte Anweisung zurück und läuft weiter — frühere Anweisungen bleiben bestehen; erstSET XACT_ABORT ONoderTRY/CATCHmit explizitemROLLBACKerzwingt die Postgres-artige Atomarität. Wer eine T-SQL-Prozedur portiert, die sich auf dieses Teil-Erfolg-Verhalten verlässt, bekommt in Postgres ein anderes Ergebnis. Dazu die Detail-Unterschiede: keinCOMMITin einerFUNCTION(nur in einerPROCEDURE), und jederEXCEPTION-Block ist eine implizite Subtransaktion. Dieser Themenkomplex ist tief genug für einen eigenen Artikel. @@ROWCOUNTund Diagnostik. Was in T-SQL@@ROWCOUNTliefert, holt man sich in PL/pgSQL überGET DIAGNOSTICS l_count = ROW_COUNT;.
Diese Liste ist kein Grund zur Sorge, sondern eine Prioritätenliste: Genau hier lohnt der zweite Blick, während die Idiom-Tabelle den Rest schnell erledigt.
FAQ
PROCEDURE? Nein. Wenn die Routine einen Wert oder ein Resultset zurückgibt — was T-SQL-Prozeduren oft per SELECT tun —, wird sie in Postgres meist eine FUNCTION. Eine PROCEDURE (seit PostgreSQL 11) gibt nichts zurück, darf aber COMMIT/ROLLBACK ausführen und ist damit für mehrschrittige Batch-Abläufe gedacht. Die Entscheidung fällt am Rückgabe-Verhalten, nicht am Quell-Objekttyp.
TRY/CATCH in PL/pgSQL? Ein BEGIN … EXCEPTION WHEN bedingung THEN … END-Block. Wichtig: PL/pgSQL reicht Fehler von selbst nach oben weiter — ein TRY/CATCH, das in T-SQL nur abfängt und sofort wieder wirft, ist überflüssig und entfällt. Einen EXCEPTION-Block setzt man nur, wo man den Fehler tatsächlich behandelt; gefangen wird über benannte Bedingungen wie unique_violation, nicht über Fehlernummern.
sp_executesql? Über EXECUTE format(…) USING …. Bezeichner (Tabellen-, Spaltennamen) setzt man mit %I ein, Werte bindet man über USING als $1, $2. Niemals Eingabewerte per || in den String kleben — das ist die SQL-Injektions-Stelle. %I und USING ziehen die Vertrauensgrenze sauber: Struktur kontrolliert, Werte typsicher gebunden.
Ja: CREATE TEMP TABLE legt eine sitzungslokale Tabelle an, die am Verbindungsende verschwindet. Häufig braucht man sie aber gar nicht — eine WITH-CTE oder eine abgeleitete Tabelle erledigt dasselbe inline und ohne den Umweg über ein physisches Objekt. Das aus T-SQL gewohnte #temp mit #-Präfix gibt es so nicht.
SCOPE_IDENTITY()? Die RETURNING-Klausel. Statt nach dem INSERT mit SCOPE_IDENTITY() den vergebenen Schlüssel nachzulesen, hängt man RETURNING customer_id INTO l_new_id direkt an den INSERT — ein Schritt, ohne die Mehrdeutigkeiten von SCOPE_IDENTITY()/@@IDENTITY. currval/lastval gehen auch, sind aber fehleranfälliger und nur die zweite Wahl.
Verwandte Artikel
Dieser Artikel ist Teil einer Serie zur Migration von SQL Server nach PostgreSQL. Die übrigen Teile:
- Überblick: Datenmigration SQL Server nach PostgreSQL — der vollständige Leitfaden (folgt)
- Datentypen: Datentyp-Mapping SQL Server → PostgreSQL — was sauber konvertiert und was kippt
- Schema: Schema-Migration SQL Server → PostgreSQL — Identity, Constraints, Defaults, Sequenzen
- Datentransfer: Daten transferieren: bcp, COPY, pgloader, ETL — welche Methode wann
- Verifikation: Migration verifizieren — Datenqualität und Zeilen-Abgleich (folgt)
Damit der portierte Code im richtigen Zielstil landet: