Migrating from SQL Server to PostgreSQL: Schema, Data, and the T-SQL Problem

8 min readSQL Server

Migrating from SQL Server to PostgreSQL

SQL Server 2016 left extended support on July 14, 2026, and per-core licensing plus the CAL model is the reason most of these migrations get budget approval. But be clear about what you're signing up for: moving the tables and data is a solved problem that takes days. Rewriting T-SQL stored procedures, functions, and triggers into PL/pgSQL is the actual project, and its size depends entirely on how much logic lives in your database.

Worth saying up front: if your workload is heavily procedural (hundreds of stored procedures, SQL Agent jobs, SSIS packages), staying on SQL Server or moving to a compatibility layer like Babelfish may be cheaper than a full rewrite. Migrate cleanly when your database is mostly tables and constraints and the logic lives in application code.

The Three Approaches

ApproachWhat it movesEffortUse when
pgloader (one-shot copy)Schema + data + indexes + FKsLowStandard schemas, maintenance window OK
tds_fdw (foreign data wrapper)Data only, pulled from PG sideMediumYou want PG-side control, selective copies
BabelfishNothing — PG speaks T-SQLMediumToo many stored procs to rewrite

There are also commercial/managed paths (AWS SCT + DMS, Azure DMS) that make sense if you're landing on a cloud-managed PostgreSQL anyway. This guide covers the self-managed tools.

Approach 1: pgloader

pgloader discovers the SQL Server schema automatically and builds tables, indexes, primary and foreign keys on the PostgreSQL side. The minimal form:

pgloader mssql://user:pass@mshost/dbname pgsql://pguser@pghost/dbname

For real migrations you want a load file:

load database
     from mssql://appuser:secret@sqlhost/sales
     into postgresql:///sales

including only table names like 'Orders', 'Customers' in schema 'dbo'

before load do $$ drop schema if exists dbo cascade; $$;

Two version notes (as of July 2026): the v3.6.9 release that apt ships is from 2022 and builds from SBCL; the v4 rewrite is a single Clojure/JVM JAR (Java 21+) that accepts the same load-file syntax but uses JDBC connection strings — jdbc:sqlserver://host;databaseName=sales — and fixes the heap-exhaustion failures v3 hits on large tables. v4 is still tagged as a dev release; test it on a staging copy first, and fall back to v3.6.9 if you hit gaps.

pgloader keeps SQL Server's dbo schema by default. Most teams ALTER SCHEMA dbo RENAME TO public afterwards (or set a rename in the load file) so tooling behaves.

Approach 2: tds_fdw

tds_fdw (2.0.5 as of September 2025, builds against PostgreSQL 13–18) exposes SQL Server tables as foreign tables inside PostgreSQL over the TDS protocol via FreeTDS:

CREATE EXTENSION tds_fdw;
 
CREATE SERVER sqlserver FOREIGN DATA WRAPPER tds_fdw
  OPTIONS (servername 'sqlhost', port '1433', database 'sales', tds_version '7.4');
 
CREATE USER MAPPING FOR postgres SERVER sqlserver
  OPTIONS (username 'appuser', password 'secret');
 
CREATE FOREIGN TABLE mssql_orders (
  id integer, customer_id integer, total_cents bigint, created_at timestamp
) SERVER sqlserver OPTIONS (schema_name 'dbo', table_name 'Orders');
 
-- Then copy at your own pace:
INSERT INTO orders SELECT * FROM mssql_orders;

You define target tables yourself (which forces you to make every type decision explicitly — a feature, not a bug) and you can transform in the SELECT. Slower than pgloader's bulk COPY path, but ideal for selective or incremental copies.

Approach 3: Babelfish

Babelfish is different in kind: it's a set of extensions (plus patches to community PostgreSQL) that make PostgreSQL speak the TDS wire protocol and execute T-SQL, including stored procedures. Your app keeps its SQL Server driver and connection string; the database underneath is PostgreSQL. It originated at AWS for Aurora but is Apache/PostgreSQL-licensed and self-hostable.

Honest assessment: Babelfish compatibility is broad but not complete — run its assessment tooling against your schema before committing. It's the right call when the stored-procedure rewrite is measured in person-years. It's the wrong call for a mostly-relational schema, where it adds a permanent compatibility layer you'll eventually want to remove anyway.

Data Type Mapping

SQL ServerPostgreSQLNotes
NVARCHAR / VARCHARtext or varcharPG is UTF-8 throughout; the N-prefix distinction disappears
DATETIMEtimestamp(3)DATETIME has 3.33 ms precision; don't map to timestamp(6) and pretend you gained precision
DATETIME2timestampClean 1:1
DATETIMEOFFSETtimestamptzTrap: timestamptz normalizes to UTC and does NOT store the offset. If you need the original zone, add a separate column
BITbooleanpgloader converts 0/1 automatically
UNIQUEIDENTIFIERuuidClean 1:1
MONEYnumeric(19,4)Never float/real
TINYINTsmallintPG has no unsigned 1-byte int; tinyint is 0–255, smallint covers it
IDENTITY(1,1)GENERATED ALWAYS AS IDENTITYpgloader creates sequences; verify they're advanced past MAX(id)
ROWVERSIONNo equivalent; use xmin for optimistic concurrency or an explicit version column
SQL_VARIANTNo equivalent; pick a type per column, or text
GEOMETRY / GEOGRAPHYPostGISSeparate extension install; test spatial queries explicitly
XMLxmlPG's xml type is thinner (no XML indexes); consider jsonb if the XML was incidental

The SQL Your Application Has to Change

Identifier behavior first, because it touches everything: SQL Server's default collations are case-insensitive; PostgreSQL is case-sensitive and folds unquoted identifiers to lowercase. WHERE email = 'Bob@X.com' silently stops matching. Fix with citext for equality-heavy columns or ILIKE/lower() at query sites, and let identifiers go lowercase rather than quoting "CamelCase" forever.

T-SQLPostgreSQLNotes
SELECT TOP 10 ...... LIMIT 10TOP with ties has no direct LIMIT form; use window functions
ISNULL(a, b)COALESCE(a, b)
GETDATE() / GETUTCDATE()now() / now() AT TIME ZONE 'UTC'
DATEADD(day, 7, d)d + interval '7 days'
DATEDIFF(day, a, b)no direct equivalent(b::date - a::date) for days; remember DATEDIFF counts boundary crossings, not elapsed time — results can differ by one
a + b (string concat)a || bSame NULL-propagation; CONCAT() exists in both and ignores NULLs
[bracket quoting]"double quotes"Better: rename to lowercase and quote nothing
SELECT ... INTO #tmpCREATE TEMP TABLE tmp AS SELECT ...
@@ROWCOUNTGET DIAGNOSTICS / RETURNING
WITH (NOLOCK)delete itPG's MVCC means readers never block writers; NOLOCK's dirty-read semantics have no equivalent and you don't want one
TRY/CATCH + THROWBEGIN ... EXCEPTION WHENPL/pgSQL blocks; subtransaction cost applies
Cross-database db2.dbo.tschemas or postgres_fdwA PG connection can't query another database directly — consolidate into schemas

MERGE exists in PostgreSQL 15+, and simple upserts are usually better as INSERT ... ON CONFLICT. Stored procedures, functions, and triggers are line-by-line rewrites into PL/pgSQL — budget them by counting objects (SELECT COUNT(*) FROM sys.procedures) before you commit to a date. SQL Agent jobs need a replacement scheduler (pg_cron, or move them to application-level jobs).

Cutover and Validation

Freeze writes, run the final load, point the application, keep SQL Server read-only for a week as the rollback path. Validate with more than row counts:

-- On both sides, per table:
SELECT COUNT(*) FROM orders;
SELECT SUM(total_cents), MIN(created_at), MAX(created_at) FROM orders;
-- Spot-check text for collation/encoding surprises:
SELECT id, customer_name FROM orders ORDER BY random() LIMIT 20;

Aggregate checksums catch type-conversion damage (truncated datetimes, money rounding) that counts miss. Running the same validation query against both engines side-by-side is where a multi-database client earns its keep — Mako connects to SQL Server and PostgreSQL simultaneously, so you can diff results without juggling sqlcmd and psql.

Common Mistakes

MistakeConsequenceFix
Underestimating the stored-procedure rewriteProject blows its timelineCount objects first; consider Babelfish above ~100 non-trivial procs
Assuming case-insensitive matchingLogins/lookups silently failcitext or lower() indexes on equality-heavy text columns
Mapping DATETIMEOFFSET to timestamptz blindlyOriginal UTC offsets lostAdd an offset column if the zone itself matters
Porting NOLOCK as a habitNothing breaks, but cargo cult persistsDelete the hints; trust MVCC
Sequences not advanced past MAX(id)Duplicate key errors on first insertsetval() per sequence after load
Skipping aggregate validationSilent conversion damageChecksum queries on both sides before cutover

For the underlying engine comparison, see PostgreSQL vs SQL Server. Re-pointing application code afterwards: the PostgreSQL connection guides cover drivers and pooling per language, and the T-SQL how-to series documents the dialect you're leaving.

Mako connects to SQL Server and PostgreSQL side-by-side with AI-powered autocomplete — useful for validating a migration query-by-query. Try it free at mako.ai.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.