Last updated: 2026-06-14
T-SQL vs PL/pgSQL: a reference for moving SQL Server code to PostgreSQL
T-SQL (Transact-SQL) is SQL Server's procedural dialect; PL/pgSQL is PostgreSQL's. The query layer — SELECT, JOIN, window functions, CTEs — is close enough that most statements move with light edits. The procedural layer — variables, control flow, error handling, the built-in functions you reach for without thinking — is where the two diverge, and it's where a SQL Server developer's muscle memory works against them. This page is a side-by-side reference for that gap.
It exists because of a specific, honest boundary in how Jam SQL Studio migrates databases. When you migrate SQL Server tables to PostgreSQL, Jam translates table shape and data mechanically — types, identity, defaults, CHECK/UNIQUE, foreign keys, indexes, and rows all come across automatically. What it will not do is auto-translate the bodies of stored procedures, functions, triggers, and views, because T-SQL and PL/pgSQL don't map 1:1 and a wrong translation is worse than an honest "port this by hand." So Jam gives you the cross-engine object inventory — which routines exist, which are missing — and this page gives you the dialect map to port them.
The big picture: three structural differences
Before the function-by-function tables, three differences explain most of the friction:
- Procedures are functions. PostgreSQL has both
FUNCTIONand (since v11)PROCEDURE, but the everyday unit is the function, and a T-SQL stored procedure that returns a result set is most naturally re-authored as a PL/pgSQL function thatRETURNS TABLEorRETURNS SETOF. A T-SQL procedure can justSELECTand the rows flow to the client; a PL/pgSQL function must declare its return shape. - The body is a quoted string. A PL/pgSQL function body lives inside a dollar-quoted literal (
$$ ... $$) with an explicitLANGUAGE plpgsql. There's no batch separator likeGO; statements end with;and the whole body is one object. - Variables are declared, not sprinkled. T-SQL lets you
DECLARE @x INTanywhere in a batch. PL/pgSQL requires all variables in a singleDECLAREsection before theBEGIN, and they have no@sigil — a bare name that can't collide with a column.
Here is the same trivial "get a customer's order count" routine in each dialect, so the shape is concrete:
-- T-SQL (SQL Server)
CREATE PROCEDURE dbo.GetOrderCount @CustomerId INT
AS
BEGIN
DECLARE @Total INT;
SELECT @Total = COUNT(*) FROM dbo.Orders WHERE CustomerId = @CustomerId;
SELECT @Total AS OrderCount;
END;-- PL/pgSQL (PostgreSQL)
CREATE FUNCTION get_order_count(p_customer_id INT)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_total INT;
BEGIN
SELECT COUNT(*) INTO v_total FROM orders WHERE customer_id = p_customer_id;
RETURN v_total;
END;
$$;Note the four changes you'll make on almost every routine: the @param becomes a named parameter, the DECLARE moves into its own section, SELECT @x = ... becomes SELECT ... INTO x, and the trailing "result set" SELECT becomes a RETURN.
Variables and control flow
| Concept | T-SQL | PL/pgSQL |
|---|---|---|
| Declare a variable | DECLARE @n INT; | n INT; (in the DECLARE block) |
| Assign a value | SET @n = 5; | n := 5; |
| Assign from a query | SELECT @n = COUNT(*) FROM t; | SELECT COUNT(*) INTO n FROM t; |
| If / else | IF @n > 0 BEGIN ... END ELSE BEGIN ... END | IF n > 0 THEN ... ELSE ... END IF; |
| Loop | WHILE @i < 10 BEGIN ... END | WHILE i < 10 LOOP ... END LOOP; |
| Loop over rows | Cursor: DECLARE ... CURSOR FOR ... + FETCH | FOR r IN SELECT ... LOOP ... END LOOP; |
| Return a scalar | RETURN @n; | RETURN n; |
| Return a result set | SELECT ... ; (implicit) | RETURN QUERY SELECT ...; (function RETURNS TABLE) |
| Print / debug | PRINT 'msg'; | RAISE NOTICE 'msg'; |
The cursor row almost always simplifies: a T-SQL cursor loop becomes a PL/pgSQL FOR r IN SELECT ... LOOP, which is both shorter and faster because it doesn't materialise a server cursor.
Error handling
T-SQL's TRY/CATCH maps to a PL/pgSQL BEGIN ... EXCEPTION block, but the surrounding semantics differ enough to call out:
| Concept | T-SQL | PL/pgSQL |
|---|---|---|
| Guarded block | BEGIN TRY ... END TRY BEGIN CATCH ... END CATCH | BEGIN ... EXCEPTION WHEN ... THEN ... END; |
| Error message | ERROR_MESSAGE() | SQLERRM |
| Error code | ERROR_NUMBER() | SQLSTATE |
| Raise an error | THROW 50000, 'msg', 1; | RAISE EXCEPTION 'msg'; |
| Re-raise current | THROW; | RAISE; |
| Catch a specific error | Inspect ERROR_NUMBER() | WHEN unique_violation THEN ... (named condition) |
-- PL/pgSQL: catch a duplicate-key violation by name, not by number
BEGIN
INSERT INTO accounts(email) VALUES (p_email);
EXCEPTION
WHEN unique_violation THEN
RAISE NOTICE 'Account % already exists', p_email;
END;The biggest conceptual shift: in PL/pgSQL the exception block automatically rolls back to an implicit savepoint taken at its BEGIN. You don't inspect XACT_STATE() or manage SET XACT_ABORT — entering the EXCEPTION handler means the statements in that block were already undone, and only that block.
Built-in functions that change name
This is the table people actually bookmark. These are the SQL Server built-ins whose name or signature changes in PostgreSQL — the silent breakers, because the T-SQL version simply doesn't exist rather than erroring helpfully.
| Purpose | SQL Server (T-SQL) | PostgreSQL |
|---|---|---|
| Null fallback | ISNULL(x, y) | COALESCE(x, y) |
| Current timestamp | GETDATE(), SYSDATETIME() | now(), current_timestamp |
| String length | LEN(s) | length(s) (note: LEN ignores trailing spaces, length doesn't) |
| Substring | SUBSTRING(s, 1, 3) | substring(s FROM 1 FOR 3) or substr(s, 1, 3) |
| Concatenate | a + b (strings) | a || b (+ is numeric only) |
| Cast | CAST(x AS INT), CONVERT(INT, x) | CAST(x AS INT) or x::int |
| Top N rows | SELECT TOP 10 ... | SELECT ... LIMIT 10 |
| Auto identity | IDENTITY(1,1) | GENERATED ALWAYS AS IDENTITY |
| New GUID | NEWID() | gen_random_uuid() |
| Date part | DATEPART(year, d), YEAR(d) | EXTRACT(YEAR FROM d) |
| Date add | DATEADD(day, 7, d) | d + INTERVAL '7 days' |
| Date diff | DATEDIFF(day, a, b) | (b - a) (date subtraction yields days) |
| String aggregate | STRING_AGG(s, ',') | string_agg(s, ',') (same — one that didn't change) |
| Conditional value | IIF(c, a, b) | CASE WHEN c THEN a ELSE b END |
| Identity of last insert | SCOPE_IDENTITY() | INSERT ... RETURNING id |
The two that bite hardest are the ones that don't error: + for string concatenation (in PostgreSQL 'a' + 'b' is a type error, and 1 + 'x' can silently misbehave), and SCOPE_IDENTITY() (PostgreSQL has no session-scoped last-insert; you use RETURNING on the INSERT itself). For a complete column-type map — the schema side of the same move — see the SQL Server ↔ PostgreSQL data type mapping reference.
Temp tables and table variables
| Concept | T-SQL | PostgreSQL |
|---|---|---|
| Session temp table | CREATE TABLE #tmp (...) | CREATE TEMP TABLE tmp (...) |
| Global temp table | CREATE TABLE ##tmp (...) | No direct equivalent (use an unlogged or normal table) |
| Table variable | DECLARE @t TABLE (...) | CREATE TEMP TABLE, or a record/array variable |
| Drop at end | Auto at session end | ON COMMIT DROP for transaction scope |
PostgreSQL temp tables are visible for the whole session by default; add ON COMMIT DROP when you want the T-SQL table-variable feel (gone at the end of the statement's transaction). One real gotcha: creating and dropping many temp tables in a hot loop bloats the system catalogs in PostgreSQL more than in SQL Server, so prefer a single temp table you TRUNCATE, or a CTE / array, over per-iteration temp tables.
Triggers: the largest rewrite
Triggers are the hardest objects to port, because the model is fundamentally different. T-SQL triggers are statement-level and expose the affected rows through the INSERTED and DELETED pseudo-tables. PostgreSQL triggers are usually row-level (FOR EACH ROW) and expose a single NEW and OLD record — and the trigger logic must live in a separate FUNCTION that the CREATE TRIGGER statement points at. There is no way to mechanically turn a set-based INSERTED/DELETED trigger into a row-based NEW/OLD one without understanding the intent. This is covered in depth in Convert SQL Server triggers to PostgreSQL.
How Jam SQL Studio fits in
Jam SQL Studio doesn't pretend to translate any of this for you — that's the point. What it does instead:
- Moves the mechanical 90%. Tables, types, identity, defaults, constraints, foreign keys, indexes, and all the data migrate across engines automatically. See Migrate SQL Server to PostgreSQL.
- Inventories the manual 10%. A cross-engine Schema Compare lists every view, procedure, function, and trigger that exists on the SQL Server side but not yet on PostgreSQL — your port checklist, by name.
- Keeps the source in front of you. Script the original T-SQL object (Script as CREATE), open a PostgreSQL query editor beside it, and rewrite using the tables above — both connections live in the same window.
The mechanical migration and this dialect reference are two halves of the same move: Jam carries the schema and data, you carry the logic, and nothing gets silently mistranslated in between.
Frequently asked questions
Is T-SQL the same as PL/pgSQL?
No. T-SQL is SQL Server's procedural dialect and PL/pgSQL is PostgreSQL's. They share ANSI SQL for queries, but their procedural layers differ in almost every detail: variables are @name in T-SQL and plain names declared in a DECLARE block in PL/pgSQL, T-SQL procedures can return result sets directly while PL/pgSQL functions return via RETURN QUERY or a refcursor, error handling is TRY/CATCH versus BEGIN ... EXCEPTION, and string and date functions are named differently (ISNULL vs COALESCE, GETDATE() vs now(), LEN vs length). Procedural code does not translate automatically between the two.
Can I convert T-SQL stored procedures to PL/pgSQL automatically?
Not reliably. The control-flow and function differences can be mapped mechanically, but T-SQL features like multiple result sets, table variables, statement-level triggers with INSERTED/DELETED pseudo-tables, and SQL Server-specific built-ins have no 1:1 PL/pgSQL equivalent, so a faithful conversion needs human judgement. Jam SQL Studio migrates table shape and data across engines automatically but deliberately leaves procedural bodies as a manual port, telling you exactly which objects need it rather than emitting a guess.
What replaces TRY/CATCH in PostgreSQL?
A BEGIN ... EXCEPTION WHEN ... THEN ... END block inside a PL/pgSQL function. Where T-SQL exposes ERROR_MESSAGE() and ERROR_NUMBER(), PL/pgSQL gives you SQLERRM and SQLSTATE, and you re-raise with RAISE rather than THROW. The transaction-control semantics differ too: a PL/pgSQL exception block rolls back to an implicit savepoint at the BEGIN, so you do not manage XACT_STATE() by hand.
Related guides
- Cross-engine database migration — the overview of the migration this dialect reference complements.
- Migrate SQL Server to PostgreSQL — the table + data migration this reference complements.
- Convert SQL Server stored procedures to PostgreSQL — a worked procedure port.
- Convert SQL Server triggers to PostgreSQL — the INSERTED/DELETED → NEW/OLD rewrite.
- T-SQL → PostgreSQL function cheat sheet — the function table above, printable.
- SQL Server ↔ PostgreSQL data type mapping — the schema-side companion.