The data is over, the schema stands — and then there are 200 stored procedures sitting there that no tool translates for you. pgloader migrates tables and data, but the logic in procedures, functions, and triggers stays behind. This is the phase that’s actually work: porting T-SQL to PL/pgSQL, line by line, with understanding instead of search-and-replace.
The good news: most of it is mechanical. A few dozen idioms (ISNULL→COALESCE, TOP→LIMIT, @variable→l_variable) cover the majority, and the structural differences are quickly grasped. This article is the reference for that — plus the honest list of the spots that demand manual work and an eye for detail.
The essentials up front:
- Structure first:
CREATE FUNCTION/PROCEDUREwith$$quoting, theDECLAREblock moves to the top, the@before variables is gone. - The idiom table translates the most common functions and constructs 1:1 — the mechanical 80%.
- Error handling:
TRY/CATCHbecomesBEGIN … EXCEPTION WHEN … END,THROWbecomesRAISE— but often you don’t need it at all, because PL/pgSQL propagates errors on its own. - Dynamic SQL is the security-critical spot:
sp_executesqlbecomesEXECUTE format(...)with%I/%L.
Prerequisite: SQL Server as the source, PostgreSQL 14+ as the target. T-SQL constructs (TRY/CATCH, @variable, TOP, ISNULL, sp_executesql, temp tables #t) are assumed; the PL/pgSQL counterparts are shown. What clean PL/pgSQL code looks like (volatility, RETURNS, naming conventions) is its own topic — the convention articles on procedures and functions are linked; here it’s about the pure translation.
Contents
- From T-SQL to PL/pgSQL: the Structural Differences
- The Idiom Translation Table
- Error Handling: TRY/CATCH Becomes EXCEPTION
- Porting Dynamic SQL Safely
- Before and After: the Whole Procedure
- What No Tool Does for You
- FAQ
- Related Articles
From T-SQL to PL/pgSQL: the Structural Differences
Before the individual functions, the frame. A T-SQL procedure and a PL/pgSQL routine look similar at first glance — BEGIN, END, DECLARE — but four things are different, and they affect every ported routine.
This is what a T-SQL procedure looks like in skeleton form:
1: CREATE PROCEDURE dbo.usp_name
2: @p_param int
3: AS
4: BEGIN
5: DECLARE @l_var int;
6: -- logic
7: END;
And the same as 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: -- logic
12: RETURN l_var;
13: END;
14: $function$;
The four differences:
- The body sits in dollar quotes. The function body is passed as a dollar-quoted string to
CREATE FUNCTIONand compiled by the PL/pgSQL handler at creation time — not executed as text at runtime. That’s why the whole body sits between$function$ … $function$(or$procedure$for procedures); the dollar quoting saves escaping single quotes inside the code. DECLAREgoes at the top, once. T-SQL allowsDECLAREanywhere; PL/pgSQL collects all local variables in oneDECLAREblock right beforeBEGIN. The@before variables and parameters drops entirely —@l_varbecomesl_var,@p_parambecomesp_param.PROCEDUREorFUNCTION? This is the first real decision. Postgres has both: aFUNCTIONreturns a value (or a table) and is the normal case for anything that yields a result. APROCEDURE(since PostgreSQL 11) returns nothing but may runCOMMIT/ROLLBACK— it’s there for multi-step batch flows. A T-SQL procedure that returns a value viaSELECTtherefore usually becomes aFUNCTION, not aPROCEDURE— and one that returns a whole result set becomes aFUNCTIONwithRETURNS TABLE/SETOF(details in the table-returning article below).- Names move straight into the target style. When porting, the code lands directly in
snake_case— you don’t drag the SQL Server PascalCase names (usp_AddCustomer) along. Thefn_/sp_prefixes in the examples are this blog’s convention, not a PostgreSQL mandate; many projects deliberately skip prefixes and use plainsnake_case. Either way: the right moment to switch is now, not later.
SET NOCOUNT ON and the like have no counterpart and simply drop — Postgres sends no “rows affected” interim messages that you’d have to suppress.
The Idiom Translation Table
The following table is the actual reference — the most common T-SQL constructs and their PL/pgSQL counterpart:
| T-SQL | PL/pgSQL | Note |
|---|---|---|
ISNULL(x, y) | COALESCE(x, y) | COALESCE is SQL-standard and n-ary; different return type than ISNULL — see below |
GETDATE() | now() / current_timestamp | now() = current_timestamp, but the transaction start time (constant per transaction); for the time at the call, clock_timestamp() |
GETUTCDATE() | now() AT TIME ZONE 'utc' | UTC time as timestamp without time zone (zone-less) |
DATEADD(day, 1, @d) | @d + interval '1 day' | date + int counts days; timestamp + int is an error — see below |
DATEDIFF(day, a, b) | (b::date - a::date) | for day ≈ date boundaries; for month/year, DATEDIFF counts boundaries — use age()/extract, not a plain subtraction |
LEN(s) | length(s) | both count characters; LEN ignores trailing spaces, length doesn’t — use length(rtrim(s)) if needed |
DATALENGTH(s) | octet_length(s) | bytes instead of characters; SQL Server nvarchar is UTF-16 (2 bytes/char), Postgres UTF-8 — byte counts diverge for non-ASCII |
SUBSTRING(s, 1, 3) | substring(s, 1, 3) | both 1-based |
CHARINDEX('x', s) | position('x' IN s) | both 0 when not found |
a + b (strings) | a || b | + is arithmetic-only in Postgres; for NULL-tolerant concatenation, concat(a, b) |
TOP (n) | LIMIT n | goes at the end of the SELECT in Postgres |
IIF(c, a, b) | CASE WHEN c THEN a ELSE b END | – |
@variable | l_variable (in DECLARE) | no @, declared at the start of the block |
@p … OUTPUT (parameter) | OUT p / INOUT p | return via named OUT parameters instead of OUTPUT |
EXEC sp · SELECT fn() (discard result) | CALL sp() · PERFORM fn() | a bare SELECT without INTO is an error in PL/pgSQL — PERFORM discards the result |
#temp | CREATE TEMP TABLE or CTE | often a CTE is enough instead of a real temp table |
OUTPUT inserted.id | RETURNING id | directly on the INSERT/UPDATE/DELETE |
SCOPE_IDENTITY() | RETURNING … INTO | the clean way; currval/lastval only with care |
sp_executesql | EXECUTE format(…) USING … | see its own section |
TRY/CATCH | BEGIN … EXCEPTION WHEN … END | see its own section |
THROW / RAISERROR | RAISE EXCEPTION … USING … | see its own section |
Three traps from the table deserve a sentence more. ISNULL vs. COALESCE: the two aren’t even type-equal in SQL Server. ISNULL(x, y) takes the data type of the first argument and truncates the second value to it — ISNULL(@v varchar(3), 'ABCDEF') yields 'ABC'. COALESCE, by contrast, follows type precedence across all arguments and doesn’t truncate ('ABCDEF'). The COALESCE ported to Postgres behaves like the SQL Server COALESCE (no truncation, verified here against Postgres 16) — if the source relies on the ISNULL truncation, the port changes the result. LEN vs. length: anyone doing a length check on a char(n) field in T-SQL often unconsciously relies on LEN stripping the trailing spaces — the naive port to length then changes the result. + vs. ||: a + accidentally carried over between two strings isn’t a typo in Postgres but a type error or — with numeric strings — a silently wrong result.
Behind this stands a fundamental stance that affects every ported computation: PostgreSQL converts far less implicitly in expressions than SQL Server. SQL Server converts generously between number and string ('1' + 1 yields 2, an int column against a varchar column is silently reconciled). Postgres does this only for untyped literals and throws an error on typed values — '1'::text + 1 is operator does not exist: text + integer.
Two consequences for porting: date arithmetic needs an interval — GETDATE() + 1 becomes now() + interval '1 day', not now() + 1 (which is an error in Postgres). And string/number mixes must be cast explicitly (::numeric, ::int).
Integer division, on the other hand, behaves the same in both systems (5 / 2 = 2) — nothing changes there when porting. The full column type mapping is covered by the data type article.
Error Handling: TRY/CATCH Becomes EXCEPTION
The translation looks mechanical at first: the BEGIN TRY … END TRY BEGIN CATCH … END CATCH of T-SQL becomes a BEGIN … EXCEPTION WHEN … END block in PL/pgSQL. THROW and RAISERROR become RAISE.
The more important point, though: you often don’t need the block at all. In T-SQL, TRY/CATCH is frequently only there to catch an error and immediately rethrow it (BEGIN CATCH THROW; END CATCH). PL/pgSQL propagates errors upward on its own — a block that only catches and rethrows is redundant and can disappear entirely. You add an EXCEPTION block only where you actually handle the error.
Here is what a real handler looks like — catching a duplicate email instead of letting the procedure abort:
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$;
Three things that stand out when porting error handling:
- Errors are caught by named codes, not numbers. Instead of the SQL Server error number (
2627for a key violation), PL/pgSQL catches via descriptivecondition nameslikeunique_violationorforeign_key_violation.WHEN OTHERSis the counterpart to the generalCATCH. THROW 50001, 'text', 1becomesRAISE EXCEPTION 'text'. The message sits directly on theRAISE. TheUSING ERRCODE = '…'clause is optional — it’s worth it only when the error should be caught specifically via aSQLSTATE; normally a bareRAISE EXCEPTION 'text'is enough. For pure notices there’sRAISE NOTICE/RAISE WARNING— the counterpart toRAISERRORwith a low severity.ERROR_MESSAGE()/ERROR_NUMBER()becomeSQLERRM/SQLSTATE. Inside theEXCEPTIONblock, these two variables give the text and code of the caught error.
A performance note that’s easy to miss: every EXCEPTION block internally opens a subtransaction (a savepoint). That costs a little per call — in a tight loop with millions of iterations, an EXCEPTION block isn’t free. This fits the mindset from type conversion: wherever possible, the error-tolerant operation (a safe conversion, an ON CONFLICT) is the cleaner path than catching a thrown exception.
Porting Dynamic SQL Safely
Here it’s not just the syntax that’s decided, but the security. T-SQL builds dynamic SQL with sp_executesql and parameterizes via @params. PL/pgSQL uses EXECUTE together with format() — and format() has two placeholders that close exactly the injection gap that string concatenation tears open:
%Iquotes an identifier (table, column name) correctly and safely.%Lquotes a literal; alternatively — and better for values — you bind parameters viaUSINGand reference them as$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$;
The pattern: insert identifiers via %I into the SQL string (at build time), bind values via $1 and USING (at execution time). Never glue an input value into the string via || — that’s the classic SQL injection spot. The trust boundary is thereby clearly drawn: structures (%I) are controlled, values (USING) are bound type-safely. The same format() pattern also carries the data quality framework that runs generic check rules via dynamic SQL — the cross-reference is below.
Before and After: the Whole Procedure
To bring it together, the running example: a T-SQL procedure that creates a customer, validates the email, and returns the new ID. First the source:
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;
And the PL/pgSQL target — because the routine returns a value, it becomes a 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$;
What changed line by line:
- Signature:
@p_email nvarchar(256)→p_email text,@p_credit_limit money = 0→p_credit_limit numeric(19, 4) DEFAULT 0(this is howmoneyis usually replaced in migrations; the type mapping is covered by the data type article). The@and the parenthesis syntax follow the PL/pgSQL form. IF … LEN(…) = 0→IF … length(…) = 0 THEN … END IF;— theTHENand theEND IF;are mandatory in PL/pgSQL.THROW→RAISE EXCEPTION … USING ERRCODE = ….INSERT+SCOPE_IDENTITY()→INSERT … RETURNING customer_id INTO l_new_id— one step instead of two, without theSCOPE_IDENTITY()trap.SELECT @l_new_id→RETURN l_new_id;— the function returns the value directly instead of sending it as a one-row result set.
What No Tool Does for You
The mechanical translation covers the majority. The “last 20%” are the spots where an automatic converter stumbles or simply gives up — and where it’s decided whether the ported code really does the same thing:
- Cursor logic. T-SQL cursors can often be replaced by a set-based statement — almost always the better solution. Where a cursor has to stay, it becomes a
FOR … IN … LOOP. That’s rethinking, not search-and-replace. MERGEsubtleties. Postgres knowsMERGE(since version 15) andINSERT … ON CONFLICT. ButON CONFLICTdoes not fully replaceMERGE— it only kicks in on unique/exclusion conflicts; for more general upsert logic,MERGEremains. The choice and the exact semantics (what happens on multiple matches) must be checked per statement.- Trigger functions. A T-SQL trigger becomes two objects in Postgres: a trigger function (the logic) and the trigger itself (the wiring to the table).
inserted/deletedbecomeNEW/OLD. - Transaction and error semantics — the biggest conceptual difference. If a routine in Postgres fails with an unhandled error, the entire transaction is rolled back — including the statements that already ran before the error (verified against Postgres 16: two
INSERTs before aRAISE→ 0 rows left). SQL Server does not necessarily do this: in the default (XACT_ABORT OFF), a runtime error often rolls back only the failing statement and keeps going — earlier statements persist; onlySET XACT_ABORT ONorTRY/CATCHwith an explicitROLLBACKforces the Postgres-style atomicity. Anyone porting a T-SQL procedure that relies on this partial-success behavior gets a different result in Postgres. On top of that, the detail differences: noCOMMITin aFUNCTION(only in aPROCEDURE), and everyEXCEPTIONblock is an implicit subtransaction. This whole topic is deep enough for an article of its own. @@ROWCOUNTand diagnostics. What T-SQL gives you via@@ROWCOUNT, you fetch in PL/pgSQL viaGET DIAGNOSTICS l_count = ROW_COUNT;.
This list isn’t cause for worry but a priority list: this is exactly where a second look pays off, while the idiom table handles the rest quickly.
FAQ
PROCEDURE? No. If the routine returns a value or a result set — which T-SQL procedures often do via SELECT — it usually becomes a FUNCTION in Postgres. A PROCEDURE (since PostgreSQL 11) returns nothing but may run COMMIT/ROLLBACK and is thus meant for multi-step batch flows. The decision is made by the return behavior, not by the source object type.
TRY/CATCH in PL/pgSQL? A BEGIN … EXCEPTION WHEN condition THEN … END block. Important: PL/pgSQL propagates errors upward on its own — a TRY/CATCH that in T-SQL only catches and immediately rethrows is redundant and drops. You add an EXCEPTION block only where you actually handle the error; catching is by named conditions like unique_violation, not by error numbers.
sp_executesql? Via EXECUTE format(…) USING …. Identifiers (table, column names) you insert with %I, values you bind via USING as $1, $2. Never glue input values into the string via || — that’s the SQL injection spot. %I and USING draw the trust boundary cleanly: structure controlled, values bound type-safely.
Yes: CREATE TEMP TABLE creates a session-local table that vanishes at the end of the connection. Often you don’t need it at all, though — a WITH CTE or a derived table does the same inline and without the detour over a physical object. The #temp with the # prefix familiar from T-SQL doesn’t exist as such.
SCOPE_IDENTITY()? The RETURNING clause. Instead of reading back the assigned key with SCOPE_IDENTITY() after the INSERT, you append RETURNING customer_id INTO l_new_id directly to the INSERT — one step, without the ambiguities of SCOPE_IDENTITY()/@@IDENTITY. currval/lastval work too, but are more error-prone and only the second choice.
Related Articles
This article is part of a series on migrating SQL Server to PostgreSQL. The remaining parts:
- Overview: Data Migration SQL Server to PostgreSQL — the complete guide (coming)
- Data types: Data Type Mapping SQL Server → PostgreSQL — What Converts Cleanly and What Breaks
- Schema: Schema Migration SQL Server → PostgreSQL — Identity, Constraints, Defaults, Sequences
- Data transfer: Transferring Data: bcp, COPY, pgloader, ETL — Which Method When
- Verification: Verifying the migration — data quality and row reconciliation (coming)
So the ported code lands in the right target style: