A migration from SQL Server to PostgreSQL rarely fails at actually copying the data. It fails at datetime, where the choice between timestamp and timestamptz is anything but cosmetic, at bit, which is not a boolean, and at money, which you’d be better off not touching in PostgreSQL at all. The SQL Server to PostgreSQL data type mapping decides whether the data arrives cleanly — or is silently corrupted without a single error message.
The good news: most types convert one-to-one. The bad news: the handful that don’t are exactly the ones that hurt.
The essentials up front:
- What converts cleanly: integers,
numeric/decimal,varchar,date— the easy half. - What breaks:
datetime,bit,money,uniqueidentifier,nvarcharandtinyinthave no one-to-one equivalent. - The invisible aspect: collation and case sensitivity behave differently in PostgreSQL than in the SQL Server default.
- What tooling takes off your plate:
pgloadermaps the standard cases automatically — the edge cases stay handwork.
Prerequisite: SQL Server 2017+ as the source, PostgreSQL 14+ as the target. PostgreSQL types are placed in context briefly on first mention — no prior Postgres knowledge required, T-SQL basics yes.
Contents
- Data Type Mapping SQL Server → PostgreSQL: the big picture
- The types that convert one-to-one
- The types that break
- Collation and case — the invisible type aspect
- A concrete CREATE TABLE — before and after
- What pgloader maps automatically — and where handwork remains
- FAQ
- Related articles
Data Type Mapping SQL Server → PostgreSQL: the big picture
Before we get into the traps, the overview. This table is the SQL Server to PostgreSQL data type mapping in short form — the rows in the upper block are uncritical, the ones in the lower block need attention.
| SQL Server | PostgreSQL | Note |
|---|---|---|
int · bigint · smallint | integer · bigint · smallint | identical, same value ranges |
decimal(p, s) · numeric(p, s) | numeric(p, s) | identical |
varchar(n) · char(n) | varchar(n) · char(n) · text | identical; text is the norm in Postgres |
float · real | double precision · real | float (= float(53)) → double precision; float(1–24) → real |
date · time | date · time | identical (time precision differs marginally) |
varbinary(n) · varbinary(max) | bytea | one binary type instead of several |
xml | xml | identical as a storage type (XQuery/indexes differ) |
datetime · datetime2 · smalldatetime | timestamp · timestamptz | timestamp is 1:1; timestamptz is a design decision (source time zone). Value ranges are a non-issue in Postgres (datetime only starts at 1753, smalldatetime ends in 2079) |
datetimeoffset | timestamptz | time zone is carried along |
bit | boolean | 0/1 → true/false, no implicit numeric cast |
money · smallmoney | numeric(19, 4) · numeric(10, 4) | avoid the Postgres money type |
uniqueidentifier | uuid | NEWID() → gen_random_uuid() |
nvarchar · nchar · ntext | text · varchar | Postgres is Unicode-native, no N prefix |
tinyint | smallint | no 1-byte int, no unsigned |
rowversion · timestamp (T-SQL) | — | no equivalent; xmin or app logic |
hierarchyid · geography · geometry | PostGIS or no equivalent | special case (see FAQ) |
sql_variant | — | no equivalent, redesign needed |
The types that convert one-to-one
There’s little to tell here — and that’s the point. The following types can be carried over with no functional consequences:
- Integers.
int,bigintandsmallinthave exactly the same names and exactly the same value ranges in PostgreSQL.integeris 4 bytes,bigint8 bytes,smallint2 bytes — just like on the other side. - Decimals — with a catch.
decimal(p, s)andnumeric(p, s)are the same in both worlds when you give an explicit precision (numericis the canonical name in PostgreSQL,decimalan alias). Watch out for the omitted argument: SQL Serverdecimalwith no spec meansdecimal(18, 0)— that is, integer. PostgreSQLnumericwith no spec, by contrast, means unbounded precision with arbitrary scale. Mapping a baredecimalone-to-one onto a barenumerictherefore changes the behaviour — write an explicitnumeric(18, 0)if you want to keep the SQL Server semantics. - Floating point. A bare SQL Server
floatisfloat(53)and corresponds todouble precision,realstaysreal. One subtlety:float(n)is internallyreal(4 bytes) for n from 1 to 24, anddouble precision(8 bytes) from 25 on — map accordingly when the precision is given explicitly. - Text with ASCII content.
varchar(n)staysvarchar(n). In PostgreSQL, though,text(with no length limit) is the idiomatic standard — you only set a length limit there when it’s a genuine business rule, not out of habit. - Date (date only).
dateis identical on both sides. - Binary data. Where SQL Server distinguishes between
binary,varbinaryandvarbinary(max), PostgreSQL knows onlybytea. Lossless in content.
This half is handled by any converter without thinking. The other half is where it gets interesting.
The types that break
datetime → timestamp / timestamptz. The real issue isn’t the value, it’s the time zone. The technical one-to-one mapping is datetime/datetime2 → timestamp (time-zone-naive → time-zone-naive) — the value carries over unchanged. PostgreSQL additionally offers timestamptz, which stores an absolute point in time. Moving to it, however, is not a pure type conversion but a design decision: timestamptz interprets the naive value relative to a time zone — and the database has no idea which time zone the source data was in (UTC? server time? Berlin?). Only switch to timestamptz if you can answer that question; otherwise timestamp is the safe choice. Second point: the value range — datetime starts only in 1753, while PostgreSQL timestamp reaches far beyond that (rarely relevant in practice, but good to know).
bit → boolean. SQL Server models yes/no as bit (0, 1 or NULL). PostgreSQL has the real type boolean for this (true/false/NULL). The column conversion itself is uncritical — pgloader handles it correctly. The trap is in the ported logic: in T-SQL, WHERE is_active = 1 is common. In PostgreSQL, is_active is already boolean, and comparing it to an integer is an error (operator does not exist: boolean = integer); likewise an integer cannot be used implicitly as a truth value (WHERE int_column is an error). The correct form is WHERE is_active or WHERE is_active = true. An explicit cast, on the other hand, works fine: 1::boolean yields true, 0::boolean yields false. So the trip-ups are the stored procedures and queries that treated bit columns like numbers (see the dedicated article on code porting).
money → numeric(19, 4). PostgreSQL does have a type called money — but you shouldn’t use it. It depends on the server’s lc_monetary locale, which makes input/output and therefore migrations fragile, and it’s awkward for calculations. The clean equivalent is numeric with a fixed scale: money → numeric(19, 4), smallmoney → numeric(10, 4). That keeps amounts exact and engine-independent.
uniqueidentifier → uuid. PostgreSQL has a native uuid type. The default value moves with it: DEFAULT NEWID() becomes DEFAULT gen_random_uuid() — that function has been in the core since PostgreSQL 13, with no extension needed. For NEWSEQUENTIALID() (sequential GUIDs) there’s no direct counterpart; here you have to choose a new strategy deliberately.
nvarchar → text / varchar. This one is often a relief. PostgreSQL databases usually run in UTF-8 — so all text is natively Unicode. There’s no separate “national” character type and no N'…' prefix for literals. nvarchar(100) becomes varchar(100) or simply text, nvarchar(max) becomes text. The N in front of strings drops away entirely. Special case JSON: if an nvarchar(max) column actually holds JSON (SQL Server had no dedicated type for it through 2022, storing it as nvarchar(max)), then jsonb — not text — is the better choice in PostgreSQL, bringing indexing, validation and JSON operators with it.
tinyint → smallint. Here you lose a property. SQL Server tinyint is 1 byte and unsigned (0–255). PostgreSQL has neither a 1-byte integer nor unsigned. The equivalent is smallint (2 bytes, −32,768 to 32,767). If you want to keep the original semantics, add a check: CHECK (column BETWEEN 0 AND 255).
rowversion / timestamp → no equivalent (and a naming trap). Careful: the SQL Server keyword timestamp is a synonym for rowversion — an auto-incrementing 8-byte binary value for concurrency control, not a date. Mapping it blindly onto the PostgreSQL timestamp (which is a date/time type) builds in a serious bug. There is no true counterpart; for optimistic locking, the xmin system column or a dedicated version column is the way to go.
The column defaults and
IDENTITYin the example below are, strictly speaking, a schema topic, not a type topic — howIDENTITYbecomesGENERATED/sequences is covered in the dedicated article on schema migration.
Collation and case — the invisible type aspect
One aspect that isn’t a data type of its own and yet belongs to type migration: sort and comparison behaviour. SQL Server is run in many installations with a case-insensitive default collation (…_CI_…) — 'Müller' = 'müller' is true there. PostgreSQL compares text case-sensitively by default and exactly by byte/locale. The same query that found a row in SQL Server suddenly finds none in PostgreSQL.
This is not a conversion error but a decision to be made deliberately. If you need case-insensitive columns, you have several options: a non-deterministic ICU collation (from PostgreSQL 12 on) directly on the column, the citext extension (a case-insensitive text type), or explicit LOWER(...) comparisons or a suitable COLLATE in the affected queries. Closely related is the question of how identifiers (table and column names) are treated with respect to case — that’s a topic of its own, covered in detail in the sister article on case sensitivity.
A concrete CREATE TABLE — before and after
To make the mapping tangible, a table that bundles all the critical types at once. First the source in T-SQL:
1: CREATE TABLE dbo.customer
2: (
3: customer_id int IDENTITY(1, 1) NOT NULL
4: ,customer_guid uniqueidentifier NOT NULL DEFAULT NEWID()
5: ,full_name nvarchar(100) NOT NULL
6: ,is_active bit NOT NULL DEFAULT 1
7: ,credit_limit money NULL
8: ,rating tinyint NULL
9: ,created_at datetime NOT NULL DEFAULT GETDATE()
10: ,CONSTRAINT pk_customer PRIMARY KEY (customer_id)
11: );
And the same as a PostgreSQL target:
1: CREATE TABLE customer
2: (
3: customer_id integer GENERATED BY DEFAULT AS IDENTITY
4: ,customer_guid uuid NOT NULL DEFAULT gen_random_uuid()
5: ,full_name text NOT NULL
6: ,is_active boolean NOT NULL DEFAULT true
7: ,credit_limit numeric(19, 4)
8: ,rating smallint CHECK (rating BETWEEN 0 AND 255)
9: ,created_at timestamptz NOT NULL DEFAULT now()
10: ,CONSTRAINT pk_customer PRIMARY KEY (customer_id)
11: );
Seven columns, seven decisions:
- Line 3:
int IDENTITY(1, 1)→integer GENERATED BY DEFAULT AS IDENTITY— the auto-value column is moved to the SQL-standard identity mechanism. - Line 4:
uniqueidentifier→uuidwithgen_random_uuid()instead ofNEWID(). - Line 5:
nvarchar(100)→text. - Line 6:
bit→booleanwithtrueinstead of1. - Line 7:
money→numeric(19, 4). - Line 8:
tinyint→smallintwith a preservingCHECKconstraint. - Line 9:
datetime→timestamptz(deliberately here, since it’s a pure audit column) withnow()instead ofGETDATE().
No converter that merely swaps the column types gets all of these decisions right automatically — money and tinyint in particular need the human correction.
What pgloader maps automatically — and where handwork remains
A widely used free tool for this migration is pgloader — it takes schema and data over in a single run directly from SQL Server to PostgreSQL, casting the types along the way. It’s not the only one: bcp/COPY, ETL pipelines and commercial tools all play a part depending on data volume and downtime — the comparison of methods is covered in Transferring Data: bcp, COPY, pgloader, ETL — Which Method When. For the uncritical half from the first section it’s a no-brainer: integers, numeric, varchar, date land correctly without you writing a single rule. pgloader also covers many of the breaking cases with its defaults — bit → boolean or nvarchar → text, for instance.
Handwork remains wherever a business decision is needed that no tool can know:
datetime→timestamportimestamptzis the time-zone decision from above — whether the tool’s default fits needs checking, not blind adoption.moneyis often cast tonumericby default — but whether the scale is right, and whether the unsuitablemoneytype sneaks in, needs checking.tinyint→smallintloses the0–255semantics; no converter adds the preservingCHECKconstraint by itself.rowversion/timestamp,sql_variant,hierarchyidand geo types have no standard mapping and must be decided one by one.- Stored procedures, triggers and default expressions with a type reference (
GETDATE(),NEWID(),bitarithmetic) are pure handwork anyway.
The rule of thumb: pgloader takes the mechanical 80% off your plate. The last 20% are exactly the types that break — and they cost the thinking this article is about.
FAQ
datetime simply become timestamp? Yes — and the values carry over losslessly, because datetime/datetime2 (time-zone-naive) map directly onto the equally naive timestamp. What should not happen automatically is the jump to timestamptz: that’s a design decision, not a type conversion, because timestamptz assumes a source time zone that the naive data simply doesn’t contain. timestamp is the safe default; timestamptz only when the source data’s time zone is known.
money type in PostgreSQL? Because the PostgreSQL money type depends on the server’s lc_monetary locale, which makes input/output and migrations fragile, and because calculations with it are cumbersome. The robust, engine-independent equivalent for SQL Server money is numeric(19, 4), and for smallmoney numeric(10, 4).
nvarchar(max)? nvarchar(max) becomes text. PostgreSQL databases are usually UTF-8 and therefore natively Unicode — there’s no separate “national” character type and no N'…' prefix. nvarchar(100) likewise simply becomes varchar(100) or text; the N in front of string literals drops away.
tinyint in PostgreSQL? No. PostgreSQL has neither a 1-byte integer nor unsigned. The equivalent for tinyint (0–255) is smallint (−32,768 to 32,767). To preserve the original value-range semantics, add a check: CHECK (column BETWEEN 0 AND 255).
uniqueidentifier default values? The type becomes uuid, and the default NEWID() becomes gen_random_uuid() — that function has been in the core since PostgreSQL 13, with no extra extension. For NEWSEQUENTIALID() (ascending GUIDs) there’s no direct counterpart; the strategy has to be chosen anew here.
Related articles
This article is part of a series on migrating from SQL Server to PostgreSQL. The other parts:
- Overview: Data Migration SQL Server to PostgreSQL — the Complete Guide
- Schema & DDL: Schema Migration SQL Server → PostgreSQL — Identity, Constraints, Defaults, Sequences
- Data transfer: Transferring Data: bcp, COPY, pgloader, ETL — Which Method When
- Code porting: Porting T-SQL to PL/pgSQL — Migrating Procedures and Functions
- Verification: Verifying the Migration — Data Quality and Row Reconciliation
For more on type conversion itself: