sqlserver-to-postgresql
Converts SQL Server / T-SQL to PostgreSQL for the dualdb tool, and decides when conversion is unsafe. Use this skill whenever you encounter T-SQL, inline SQL in .NET code, a .sql file, a SQL Server da
SKILL.mdskills/sqlserver-to-postgresql/SKILL.md
GitHubSQL Server -> PostgreSQL conversion
The hub skill. Everything else in skills/ is a specialisation of the decision
procedure below.
When to use
- Any T-SQL statement, anywhere: a string literal in C#, a
.sqlfile, an.edmx, a migration, aHasDefaultValueSql, a Dapper call. - Any SQL Server data type or function name.
- Any question of the form "does X behave the same on PostgreSQL?" - the answer is usually "not exactly", and the rule pack says how.
- Before writing a single line of PostgreSQL SQL. Not after.
Decision procedure
- Identify the enclosing data-access unit - normally the whole method, or a
whole
.sqlbatch. You convert the unit, never individual lines. Converting the SQL string and leavingSqlParameter,SqlDbTypeandreader.GetBooleanin place produces code that compiles and then fails at run time. - Normalize the statement. If it contains unresolved dynamic fragments
(
PG-DYN-004) ->manual-review. Do not guess at what the fragment contains. - Match rules from
knowledge/rules/*.yaml. Collect the tiers. - If any hit is
tier: manual->manual-review, with the rule's two to three options carried into the report. - If a hit has
requiresSchema: trueand you do not have that schema ->manual-review. Do not infer schema. This is the most important behavioural rule in the tool. - If the file is a pure SQL handler, or a
.sqlfile ->add-pg-file: write a complete parallel PostgreSQL implementation and leave the original untouched. - Otherwise ->
add-pg-alternate: write a complete PostgreSQL counterpart of the whole unit, including connection acquisition, parameter binding, execution, reader access and error handling. Record everybehaviour-riskhit. The original unit is not edited. portable-rewriteonly if every condition holds: the statement is fully resolved, every rule hit istier: auto, there are zerobehaviour-riskhits, and no provider-specific .NET API is involved. When in doubt, go to step 7 - an extra method costs nothing and a silent regression in a shared path costs a great deal.
Rule table
<!-- dualdb:rules:begin categories=identifiers,pagination,literals,nulls,strings,expressions,conversion,formatting,dml,joins,session,scripts,system,aggregation --> <!-- generated from knowledge/rules/*.yaml by `dualdb rules sync` - do not edit -->| id | SQL Server | PostgreSQL | tier | note |
|---|---|---|---|---|
PG-FUNC-001 |
SELECT FirstName + ' ' + LastName FROM dbo.Person |
SELECT FirstName || ' ' || LastName FROM dbo.Person |
review | String concatenation: + -> || or CONCAT |
PG-FUNC-002 |
SELECT LEN(Code) FROM dbo.Product |
SELECT LENGTH(Code) FROM dbo."Product" |
review | LEN(s) -> LENGTH(s) |
PG-FUNC-003 |
SELECT DATALENGTH(Notes) FROM dbo.Order |
SELECT OCTET_LENGTH("Notes") FROM dbo."Order" |
auto | DATALENGTH(s) -> OCTET_LENGTH(s) |
PG-FUNC-004 |
SELECT CHARINDEX('@', Email) FROM dbo.Person |
SELECT POSITION('@' IN "Email") FROM dbo."Person" |
review | CHARINDEX(needle, haystack) -> POSITION / STRPOS |
PG-FUNC-005 |
SELECT IIF(IsActive = 1, 'yes', 'no') FROM dbo.[Order] |
SELECT CASE WHEN IsActive = 1 THEN 'yes' ELSE 'no' END FROM dbo.[Order] |
auto | IIF(c, a, b) -> CASE WHEN |
PG-FUNC-006 |
SELECT FORMAT(Total, 'N2') FROM dbo.[Order] |
SELECT to_char("Total", 'FM999,999,990.00') FROM dbo."Order" |
review | FORMAT(v, fmt) / STR(n) -> to_char |
PG-FUNC-009 |
DELETE FROM dbo.[Order] OUTPUT DELETED.Id WHERE IsActive = 0 |
DELETE FROM dbo."Order" WHERE "IsActive" = FALSE RETURNING "Id" |
review | OUTPUT INSERTED/DELETED -> RETURNING |
PG-FUNC-010 |
MERGE dbo.Stock AS t USING (SELECT @sku AS Sku, @qty AS Qty) AS s ON t.Sku = s.Sku WHEN... |
INSERT INTO dbo."Stock" ("Sku", "Qty") VALUES (@sku, @qty) ON CONFLICT ("Sku") DO UPDAT... |
review | MERGE -> INSERT ... ON CONFLICT DO UPDATE |
PG-FUNC-011 |
UPDATE dbo.[Order] SET IsActive = 0 WHERE Id = @id; SELECT @@ROWCOUNT; |
UPDATE dbo."Order" SET "IsActive" = FALSE WHERE "Id" = @id |
auto | @@ROWCOUNT -> ExecuteNonQuery's return value |
PG-FUNC-012 |
UPDATE o SET o.IsActive = 0 FROM dbo.[Order] AS o JOIN dbo.Customer AS c ON c.Id = o.Cu... |
UPDATE dbo."Order" AS o SET "IsActive" = FALSE FROM dbo."Customer" AS c WHERE c."Id" = ... |
auto | UPDATE ... FROM join syntax |
PG-FUNC-013 |
DELETE o FROM dbo.[Order] o JOIN dbo.Customer c ON c.Id = o.CustomerId WHERE c.IsClosed... |
DELETE FROM dbo."Order" o USING dbo."Customer" c WHERE c."Id" = o."CustomerId" AND c."I... |
auto | DELETE ... FROM join syntax -> DELETE ... USING |
PG-FUNC-014 |
SELECT o.Id, x.Total FROM dbo.[Order] o CROSS APPLY (SELECT SUM(Amount) AS Total FROM d... |
SELECT o."Id", x.total FROM dbo."Order" o CROSS JOIN LATERAL (SELECT SUM("Amount") AS t... |
auto | CROSS APPLY / OUTER APPLY -> LATERAL |
PG-FUNC-015 |
SET NOCOUNT ON; |
no direct equivalent |
auto | SET NOCOUNT / ANSI_NULLS / QUOTED_IDENTIFIER |
PG-FUNC-016 |
CREATE TABLE dbo.[Order] (Id int); GO CREATE INDEX IX ON dbo.[Order] (Id); |
two separate commands, no GO |
auto | GO is not SQL |
PG-FUNC-017 |
SELECT STUFF((SELECT ',' + Name FROM dbo.Tag WHERE OrderId = o.Id FOR XML PATH('')), 1,... |
SELECT (SELECT STRING_AGG("Name", ',' ORDER BY "Name") FROM dbo."Tag" WHERE "OrderId" =... |
review | STUFF(... FOR XML PATH('')) -> STRING_AGG |
PG-FUNC-018 |
SELECT Id FROM dbo.[Order] WHERE CustomerId IN (SELECT value FROM STRING_SPLIT(@ids, ',')) |
SELECT "Id" FROM dbo."Order" WHERE "CustomerId" = ANY(@ids) |
auto | STRING_SPLIT -> string_to_array + unnest |
PG-FUNC-019 |
SELECT * FROM (SELECT Year, Region, Total FROM dbo.Sales) s PIVOT (SUM(Total) FOR Regio... |
SELECT "Year", SUM(CASE WHEN "Region" = 'North' THEN "Total" END) AS "North", SUM(CASE ... |
review | PIVOT / UNPIVOT -> conditional aggregation |
PG-FUNC-020 |
SELECT Name + SPACE(4) + Code FROM dbo.Product |
SELECT "Name" || repeat(' ', 4) || "Code" FROM dbo."Product" |
auto | SPACE(n) -> repeat(' ', n) |
PG-FUNC-021 |
SELECT @@VERSION |
SELECT version() |
auto | @@VERSION -> version() |
PG-FUNC-022 |
TRUNCATE TABLE dbo.Staging |
TRUNCATE TABLE dbo."Staging" |
auto | TRUNCATE TABLE is portable |
PG-FUNC-026 |
INSERT INTO REMOTEDW.Warehouse.dbo.OrderFact SELECT Id, Total FROM dbo.[Order] |
no direct equivalent |
manual | Linked servers and OPENQUERY have no PostgreSQL equivalent |
PG-IDENT-001 |
SELECT [Id], [CustomerName] FROM dbo.[Order] |
SELECT "Id", "CustomerName" FROM dbo."Order" |
auto | [bracket] quoting -> "double quote" quoting |
PG-IDENT-002 |
SELECT Id FROM dbo.[Order] |
SELECT "Id" FROM dbo."Order" |
auto | dbo schema qualification |
PG-IDENT-003 |
CREATE INDEX IX_CatalogItem_Name_Including_Price_And_CreatedUtc_For_Search ON dbo.Catal... |
CREATE INDEX "IX_CatalogItem_Name_Search" ON dbo."CatalogItem" ("Name") |
review | PostgreSQL truncates identifiers at 63 bytes |
PG-IDENT-004 |
CREATE TABLE dbo.[CatalogItem] ([CatalogItemId] int) |
CREATE TABLE dbo."CatalogItem" ("CatalogItemId" int) |
behaviour-risk | PostgreSQL folds unquoted identifiers to lower case |
PG-NULL-001 |
SELECT ISNULL(Discount, 0) FROM dbo.Orders |
SELECT COALESCE(Discount, 0) FROM dbo.Orders |
auto | ISNULL(a, b) -> COALESCE(a, b) |
PG-NULL-002 |
SELECT COUNT(*) FROM dbo.[Order] WHERE Notes = '' |
SELECT COUNT(*) FROM dbo."Order" WHERE "Notes" = '' |
auto | Empty string and NULL are distinct on both engines |
PG-NULL-003 |
SELECT FirstName + ' ' + LastName FROM dbo.Person WHERE MiddleName IS NULL |
SELECT "FirstName" || ' ' || "LastName" FROM dbo."Person" |
behaviour-risk | CONCAT skips NULLs; + and || propagate them |
PG-PAG-001 |
SELECT TOP 10 Id FROM dbo.Orders ORDER BY Id |
SELECT Id FROM dbo.Orders ORDER BY Id LIMIT 10 |
auto | SELECT TOP n -> LIMIT n |
PG-PAG-002 |
SELECT TOP 10 PERCENT Id FROM dbo.Orders ORDER BY Total DESC |
SELECT Id FROM (SELECT Id, ntile(10) OVER (ORDER BY Total DESC) AS bucket FROM dbo.Orde... |
review | TOP n PERCENT / WITH TIES |
PG-PAG-003 |
SELECT Id FROM dbo.Orders ORDER BY Id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY |
SELECT Id FROM dbo.Orders ORDER BY Id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY |
auto | OFFSET/FETCH is portable |
PG-PAG-004 |
DELETE TOP (1000) FROM dbo.AuditLog WHERE CreatedUtc < @cutoff |
DELETE FROM dbo."AuditLog" WHERE "Id" IN (SELECT "Id" FROM dbo."AuditLog" WHERE "Create... |
review | DELETE TOP (n) / UPDATE TOP (n) |
PG-TYPE-001 |
SELECT * FROM dbo.[Order] WHERE CustomerName = N'Müller' |
SELECT * FROM dbo."Order" WHERE "CustomerName" = 'Müller' |
auto | N'literal' national character prefix |
PG-TYPE-002 |
SELECT CONVERT(varchar(10), Id) FROM dbo.[Order] |
SELECT CAST(Id AS varchar(10)) FROM dbo.[Order] |
auto | CONVERT(type, value) -> CAST(value AS type) |
PG-TYPE-003 |
SELECT TRY_CONVERT(int, Code) FROM dbo.Import |
SELECT CASE WHEN "Code" ~ '^-?[0-9]+$' THEN "Code"::int END FROM dbo."Import" |
manual | TRY_CONVERT / TRY_CAST have no equivalent |
PG-TYPE-004 |
WHERE ISNUMERIC(Code) = 1 |
WHERE "Code" ~ '^-?[0-9]+$' |
manual | ISNUMERIC has no equivalent |
Full detail per area is in references/:
| Reference | Covers |
|---|---|
| datatypes.md | Appendix C, GUID ordering, rowversion |
| functions.md | string, math and system functions |
| datetime.md | DateTime.Kind, precision, ranges, Npgsql switches |
| nulls-and-collation.md | ISNULL, case sensitivity, sort order |
| pagination.md | TOP, LIMIT, OFFSET/FETCH, ordering |
| identity-and-sequences.md | identity, RETURNING, sequence re-sync |
| temp-tables-and-tvs.md | #temp, table variables, TVPs |
| transactions-and-hints.md | isolation, NOLOCK, 40001 retry |
| dynamic-sql.md | sp_executesql, unresolved fragments |
| errors-and-sqlstates.md | error numbers to SQLSTATEs |
| json-and-xml.md | FOR JSON, OPENJSON, XML methods |
| identifiers-and-casing.md | quoting, folding, reserved words, 63 bytes |
| engine-limits.md | parameter counts, identifier length, precision |
Worked examples
1. add-pg-alternate - a paged query with a hint
SELECT TOP (@take) Id, CustomerName, IsActive, CreatedUtc
FROM dbo.[Order] WITH (NOLOCK)
WHERE CustomerId = @customerId
ORDER BY CreatedUtc DESC
Rules: PG-PAG-001 (auto), PG-HINT-001 (manual), PG-IDENT-001 (auto),
PG-TYPE-005 (review, via IsActive), PG-API-001 (review, the reader reads by
column name).
PG-HINT-001 is tier: manual, so by step 4 this is manual-review — unless
the NOLOCK is classified as habitual, which it is here: the query reads for
display and nothing depends on seeing uncommitted rows. With that classification
recorded, the decision is add-pg-alternate:
SELECT "Id", "CustomerName", "IsActive", "CreatedUtc"
FROM dbo."Order"
WHERE "CustomerId" = @customerId
ORDER BY "CreatedUtc" DESC
LIMIT @take
Behaviour risks recorded: PG-TXN-004 (the hint's removal), PG-ORD-001 if
CreatedUtc is nullable. The C# alternate binds a bool for IsActive and reads
it with GetBoolean.
2. portable-rewrite - the narrow case
SELECT ISNULL(Discount, 0) FROM dbo.Orders
One rule hit: PG-NULL-001, tier: auto, no behaviour risk, statement fully
resolved, no provider-specific .NET API in the unit (it runs through
DbConnection already). COALESCE is valid on both engines, so one statement can
serve both:
SELECT COALESCE(Discount, 0) FROM dbo.Orders
This is the only shape that qualifies. Note what is absent: no bracket
identifiers, no reader-by-name mapping, no SqlParameter.
3. manual-review - a statement that must not be converted
SELECT COUNT(*) FROM dbo.ImportBatch WITH (NOLOCK) WHERE Status = 0
Identical syntax to example 1, and a completely different answer. This query polls
a batch that a long-running import transaction is still writing, and the whole
point of the NOLOCK is to see the in-flight rows. On PostgreSQL that is not
possible at any isolation level.
PG-HINT-001 fires as noEquivalent, so the output is a MANUAL_REVIEW.md entry
carrying its three options - remove the hint, restructure into one transaction, or
stage the rows - with the trade-offs, and no generated alternate. A missing
artifact is recoverable; a wrong one that compiles is not.
Traps
- Two identical-looking
NOLOCKs, two different answers. Examples 1 and 3 differ only in what the caller depends on. This is why every occurrence is classified rather than deleted on sight. CONCATis not+.PG-NULL-003:CONCATskips NULLs,+and||propagate them. The rewrite is valid, plausible, and changes results.LENis notLENGTH.PG-FUNC-002:LENignores trailing spaces.- The
.NEThalf is half the work. Reader casing (PG-API-001),ExecuteScalartypes (PG-API-002),bitreads (PG-TYPE-005) and exception types (PG-ERR-001) all break independently of the SQL. TIMESTAMPin T-SQL isrowversion.PG-TYPE-008. Mapping it to a PostgreSQLtimestampcorrupts data and the migration succeeds.
Escalate when
Any of these forces manual-review regardless of everything else:
- an unresolved dynamic fragment (
PG-DYN-004) - any rule with
noEquivalent: true - a rule with
requiresSchema: trueand no schema hints supplied - a
behaviour-riskhit the classification cannot resolve from the code alone - the unit is
mixed(data access plus business logic) and the data access cannot be cleanly separated - duplicating business logic is forbidden
Do not
- Do not convert at line granularity when the unit is a method.
- Do not edit the SQL Server statement. Ever. Add beside it.
- Do not infer schema - nullability, collation, precision, whether a
bitis read asboolorint. If the decision needs schema you do not have, the answer ismanual-review. - Do not "improve" the SQL while porting it: no added indexes, no rewritten joins, no caching, no changed pagination semantics.
- Do not emit a plausible-looking wrong function or statement. Emit a stub and a review entry.
- Do not introduce PostgreSQL arrays or native
enuminto the shared model - they have no SQL Server equivalent.