T-SQL nach PL/pgSQL portieren — Prozeduren und Funktionen migrieren

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 (ISNULLCOALESCETOPLIMIT@variablel_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/PROCEDURE mit $$-Quoting, der DECLARE-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/CATCH wird BEGIN … EXCEPTION WHEN … ENDTHROW wird RAISE — aber oft brauchst du es gar nicht, weil PL/pgSQL Fehler von selbst weiterreicht.
  • Dynamisches SQL ist die Sicherheits-Stelle: sp_executesql wird EXECUTE format(...) mit %I/%L.

Voraussetzung: SQL Server als Quelle, PostgreSQL 14+ als Ziel. T-SQL-Konstrukte (TRY/CATCH@variableTOPISNULLsp_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

Bevor die einzelnen Funktionen dran sind, der Rahmen. Eine T-SQL-Prozedur und eine PL/pgSQL-Routine sehen auf den ersten Blick ähnlich aus — BEGINENDDECLARE —, 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.
  • DECLARE steht am Anfang, einmal. T-SQL erlaubt DECLARE an beliebiger Stelle; PL/pgSQL sammelt alle lokalen Variablen in einem DECLARE-Block direkt vor BEGIN. Das @ vor Variablen und Parametern entfällt komplett — aus @l_var wird l_var, aus @p_param wird p_param.
  • PROCEDURE oder FUNCTION? Hier liegt die erste echte Entscheidung. Postgres hat beides: Eine FUNCTION gibt einen Wert (oder eine Tabelle) zurück und ist der Normalfall für alles, was ein Ergebnis liefert. Eine PROCEDURE (seit PostgreSQL 11) gibt nichts zurück, darf dafür aber COMMIT/ROLLBACK ausführen — sie ist für mehrschrittige Batch-Abläufe da. Eine T-SQL-Prozedur, die per SELECT einen Wert zurückgibt, wird in Postgres deshalb meist eine FUNCTION, keine PROCEDURE — und eine, die ein ganzes Resultset liefert, eine FUNCTION mit RETURNS 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. Die fn_-/sp_-Präfixe in den Beispielen sind die Konvention dieses Blogs, kein PostgreSQL-Gebot; viele Projekte verzichten bewusst auf Präfixe und nutzen nur snake_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-SQLPL/pgSQLHinweis
ISNULL(x, y)COALESCE(x, y)COALESCE ist SQL-Standard und n-ary; anderer Rückgabetyp als ISNULL — siehe unten
GETDATE()now() / current_timestampnow() = 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 ZeichenLEN 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 nsteht in Postgres am Ende des SELECT
IIF(c, a, b)CASE WHEN c THEN a ELSE b END
@variablel_variable (im DECLARE)kein @, Deklaration am Block-Anfang
@p … OUTPUT (Parameter)OUT p / INOUT pRü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
#tempCREATE TEMP TABLE oder CTEoft reicht eine CTE statt einer echten Temp-Table
OUTPUT inserted.idRETURNING iddirekt am INSERT/UPDATE/DELETE
SCOPE_IDENTITY()RETURNING … INTOder saubere Weg; currval/lastval nur mit Vorsicht
sp_executesqlEXECUTE format(…) USING …siehe eigener Abschnitt
TRY/CATCHBEGIN … EXCEPTION WHEN … ENDsiehe eigener Abschnitt
THROW / RAISERRORRAISE 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 (2627 für eine Schlüsselverletzung) fängt PL/pgSQL über sprechende condition names wie unique_violation oder foreign_key_violationWHEN OTHERS ist das Pendant zum allgemeinen CATCH.
  • THROW 50001, 'text', 1 wird RAISE EXCEPTION 'text'. Die Meldung steht direkt am RAISE. Die USING ERRCODE = '…'-Klausel ist optional — sie lohnt nur, wenn der Fehler gezielt über einen SQLSTATE gefangen werden soll; im Normalfall genügt das nackte RAISE EXCEPTION 'text'. Für reine Hinweise gibt es RAISE NOTICE/RAISE WARNING — das Pendant zu RAISERROR mit niedrigem Schweregrad.
  • ERROR_MESSAGE()/ERROR_NUMBER() werden SQLERRM/SQLSTATE. Innerhalb des EXCEPTION-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:

  • %I quotet einen Bezeichner (Tabellen-, Spaltenname) korrekt und sicher.
  • %L quotet ein Literal; alternativ — und besser für Werte — bindet man Parameter über USING und 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 wird money bei 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; — das THEN und das END 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 die SCOPE_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 kennt MERGE (seit Version 15) und INSERT … ON CONFLICTON CONFLICT ersetzt MERGE aber nicht vollständig — es greift nur bei Unique-/Exclusion-Konflikten; für allgemeinere Upsert-Logik bleibt MERGE. 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/deleted werden zu NEW/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 einem RAISE → 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; erst SET XACT_ABORT ON oder TRY/CATCH mit explizitem ROLLBACK erzwingt 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: kein COMMIT in einer FUNCTION (nur in einer PROCEDURE), und jeder EXCEPTION-Block ist eine implizite Subtransaktion. Dieser Themenkomplex ist tief genug für einen eigenen Artikel.
  • @@ROWCOUNT und Diagnostik. Was in T-SQL @@ROWCOUNT liefert, holt man sich in PL/pgSQL über GET 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

Wird aus jeder T-SQL-Prozedur eine Postgres-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.

Was ersetzt 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.

Wie übersetze ich 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.

Gibt es Temp-Tables in Postgres?

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.

Was wird aus 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()/@@IDENTITYcurrval/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:

Damit der portierte Code im richtigen Zielstil landet: