Convert Postgres data types to Snowflake: the full mapping table, the verification query for every risky type, and the two Snowflake documents that disagree about your columns
12 min read Migration The Adapters team
Last updated September 2026
Field mapping auto-plugged · tap a port to rewire
Plug a source port into
Transform on this cable
JSON in
JSON out
5 sample records ready
Most Postgres types have an obvious Snowflake target, and a connector will pick it without complaint. The full mapping table is below. Two things make this route harder than it looks. First, Snowflake publishes two separate mappings for it, in its SnowConvert translation reference and in its Openflow connector documentation, and they give different answers for timestamps, arrays and bit strings. Second, a third of these conversions produce valid DDL, load with no error, reconcile perfectly on row counts, and change what your data means. This page gives every mapping, then gives the actual query that proves each risky one is safe, run against Postgres before the load rather than against Snowflake three weeks after go-live.
Key takeaways
- A money column maps to FLOAT by default. PostgreSQL's own documentation says floating point should not be used for money. Override it to NUMBER(38,2) before the first load.
- Snowflake publishes two mappings for this route. They disagree on timestamptz, on time with time zone and on arrays, so which document your vendor read decides what your columns become.
- timestamptz belongs in TIMESTAMP_LTZ, not TIMESTAMP_TZ. Postgres does not retain the original offset, and only TIMESTAMP_LTZ behaves the same way.
- Every integer type becomes NUMBER(38,0). Three separate range guards stop being enforced, and CHECK constraints are how you rebuild the ones that mattered.
- Snowflake has no interval column type at all. Durations become text and every query doing arithmetic on them needs rewriting.
- Watch the replication slot before you watch the pipeline. An abandoned slot retains write ahead log until the source database is in trouble.
How does Postgres to Snowflake data type mapping work?
Sort the columns into three piles. Pile one converts cleanly and needs no thought: boolean, date, timestamp without time zone, double precision, varchar, text and jsonb all have honest equivalents, and jsonb arguably improves in the move. Pile two has no target at all and both Snowflake documents will tell you so: interval is the big one, because Snowflake states plainly that "INTERVAL is not a data type (that is, you can't define a table column to be of data type INTERVAL)". Pile three is the dangerous one. Those columns convert, load and behave differently, and nothing in the migration mentions it.
Pile three is invisible to the tooling for a structural reason. A connector can tell you that a type has no target, because that is a fact about its own mapping table. It cannot tell you that a column which converted perfectly well was carrying a range the target will not enforce, or that the comparison semantics of a padded code column just changed. Those are facts about your data and your application, and the only place the evidence still exists is the Postgres database you are about to demote to a source.
Why do two Snowflake documents disagree about the same column?
Because they were written for different jobs. The SnowConvert PostgreSQL reference exists to translate DDL and SQL, so it favors keeping the source type name where a same-named Snowflake type exists, which is why it maps SMALLINT to SMALLINT. The Openflow connector documentation describes what a running pipeline actually writes, so it collapses the integer family into INT and says so. Neither is lying. They are answering different questions, and if you read only one you will get a different schema than a colleague who read the other.
The practical consequence is that "we follow Snowflake's documented mapping" is not an answer, and it is worth asking any vendor on this route which of the two they followed. On timestamptz the connector is right and the translation reference is not. On the integer family the connector is more honest, though both end at the same 38 digit number. On money, only the connector has an opinion, and it is the wrong one.
Postgres to Snowflake data type mapping table
The target column is what the two Snowflake documents produce between them, checked against the PostgreSQL manual and the Snowflake SQL reference on 3 September 2026. Rows marked "see below" are the ones that look equivalent and are not.
| PostgreSQL | Snowflake | Note |
|---|---|---|
| boolean, bool | BOOLEAN | Clean. Both engines have a real boolean, unlike the MySQL and SQL Server routes |
| smallint, int2 | NUMBER(38,0) | The -32768 to +32767 guard stops being enforced. See the verification table below |
| integer, int4 | NUMBER(38,0) | Same at four bytes. Overflow that failed loudly now succeeds silently |
| bigint, int8 | NUMBER(38,0) | Same again, and one Snowflake table gives these two spellings different targets |
| smallserial, serial, bigserial | NUMBER(38,0) with IDENTITY | Not true types in Postgres. You get the column and lose the sequence position |
| numeric(p,s), decimal(p,s) | NUMBER(p,s) | Clean while p is 38 or under. Snowflake allows no more precision than that |
| numeric, no precision | NUMBER(38,s) | Postgres allows 131072 digits before the point. See the verification table below |
| real, float4 | FLOAT | Widens from four bytes to eight. Sums stop matching a source that kept 4 byte arithmetic |
| double precision, float8 | FLOAT | Clean. Both 64 bit IEEE 754 |
| money | FLOAT by connector default | Change this one by hand. Postgres says not to use floating point for money. See below |
| date | DATE | Clean. No behavior change worth planning for |
| time, time without time zone | TIME | Clean. Snowflake supports up to nine digits of fractional precision |
| time with time zone, timetz | TIME or TIMESTAMP_TZ | No Snowflake equivalent exists. The two Snowflake docs disagree. See below |
| timestamp without time zone | TIMESTAMP_NTZ | Clean. Both store wall clock time with no zone attached |
| timestamp with time zone, timestamptz | TIMESTAMP_LTZ | One Snowflake doc says TIMESTAMP_TZ instead, which changes behavior. See below |
| interval | VARCHAR | Snowflake cannot define a column as INTERVAL at all. Every duration becomes text |
| character varying, varchar | VARCHAR | Postgres stores about 1 GB, Snowflake caps at 128 MB, connectors default lower still |
| text | VARCHAR | Same three ceilings. Openflow defaults to 16 MB unless you raise it |
| character(n), bpchar | VARCHAR | Trailing spaces stop being ignored in comparisons. See the verification table below |
| bytea | BINARY | Postgres holds roughly 1 GB, the connector default accepts 8 MB. Raisable, not raised |
| uuid | VARCHAR or TEXT | Ordering becomes Unicode code point order. Keyset pagination on it changes sequence |
| json, jsonb | VARIANT | The one place Snowflake is genuinely better. SnowConvert does not list either type |
| type[] (arrays) | ARRAY or TEXT | Depends which Snowflake tool you used. See the verification table below |
| inet, cidr, macaddr | TEXT | Subnet containment operators and address ordering become string comparisons |
| tsvector, tsquery | TEXT | Full text search does not come across usefully. Rebuild it or leave it in Postgres |
| point, line, polygon, circle | TEXT | Geometric types become strings. Snowflake GEOGRAPHY needs WKT or GeoJSON input |
| enum types | VARCHAR | The allowed value list is not carried. Rebuild it as a CHECK constraint, which is enforced |
| domain types | The underlying base type | Any CHECK the domain carried is lost unless you re-declare it on the column |
Eight risky mappings, and the query that proves each one is safe
This is the part no mapping table carries. For each risky conversion below there is a query you can run against Postgres today, before you load anything, that tells you whether the risk is theoretical or whether it is already sitting in your data. All eight run in an afternoon and they are the cheapest work in the entire migration. If you need them expanded per column across a few hundred tables, describing the check in plain English and letting something turn the question into SQL is a faster route than writing three hundred variants by hand.
money columns
Snowflake's Openflow connector maps a Postgres money column to FLOAT. PostgreSQL's own money documentation says, in as many words, "Floating point numbers should not be used to handle money due to the potential for rounding errors." This is the one row on the page that produces a finance reconciliation you cannot explain.
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE data_type = 'money'
ORDER BY 1, 2;
How to read the result: Every row is a column heading for FLOAT unless you override it. Declare each as NUMBER(38,2) in Snowflake and cast on the source with amount::numeric, never through a float. Note also that Postgres decides the fractional digits from lc_monetary, so confirm that setting matches what you assume.
numeric columns with no declared precision
PostgreSQL states an unconstrained numeric column can store "up to 131072 digits before the decimal point" and 16383 after it. Snowflake's NUMBER stops at 38 digits of precision. One Snowflake mapping covers this with the six words "within Snowflake limitations" and the other does not mention it at all.
SELECT table_name, column_name
FROM information_schema.columns
WHERE data_type = 'numeric'
AND numeric_precision IS NULL;
How to read the result: These columns have no declared precision, so the source imposes almost no limit and the target imposes 38 digits. For each one, measure what is really in there before you pick a target: SELECT max(length(replace(trim(leading '-' from col::text), '.', ''))) FROM tbl. If that comes back under 38 you are safe, and now you know rather than assume.
integer range guards you are about to lose
Snowflake documents SMALLINT as "Synonymous with NUMBER, except that precision and scale can't be specified (that is, it always defaults to NUMBER(38, 0))". So smallint, integer and bigint all land in the same 38 digit number and three separate range guards stop existing. One Snowflake mapping keeps the names, which reads as though the ranges survived.
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE data_type IN ('smallint','integer','bigint')
AND table_schema = 'public'
ORDER BY data_type, table_name;
How to read the result: Each row is a constraint your schema enforced for free and Snowflake will not. You do not need to rebuild all of them. Pick the ones that were load bearing, typically status codes, quantities and anything a downstream system writes back to, and re-express those as CHECK constraints, which Snowflake does always enforce on standard tables.
timestamptz landing in the wrong timestamp type
The two Snowflake documents disagree here and the difference is behavioral. PostgreSQL stores timestamptz as UTC and states "the originally stated or assumed time zone is not retained", rendering it in the session zone on output. Snowflake's TIMESTAMP_LTZ does exactly that. TIMESTAMP_TZ instead stores UTC "together with an associated time zone offset", an offset your source never kept.
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE data_type LIKE 'timestamp%'
ORDER BY data_type;
SHOW timezone;
How to read the result: Every row reading "timestamp with time zone" should be declared TIMESTAMP_LTZ in Snowflake, not TIMESTAMP_TZ. The second statement tells you what zone your Postgres has been rendering in, which is the zone your existing reports assume. Set the Snowflake TIMEZONE parameter to match it deliberately, or daily aggregates will move rows across day boundaries.
time with time zone columns
Snowflake has no time with time zone type. One Snowflake document maps it to TIME and drops the offset. The other maps it to TIMESTAMP_TZ, which keeps the offset by inventing a date. Both are lossy in opposite directions. PostgreSQL is on your side here: "We do not recommend using the type time with time zone."
SELECT table_name, column_name
FROM information_schema.columns
WHERE data_type = 'time with time zone';
How to read the result: If this returns nothing, skip the problem entirely, which is the common case. If it returns rows, fix them in Postgres before the migration rather than arguing about the target: either promote the column to timestamptz, or split it into a plain time plus an explicit zone column. Migrating a type neither engine agrees on is the expensive way to do it.
character(n) columns and trailing spaces
PostgreSQL says that for character(n), "trailing spaces are treated as semantically insignificant and disregarded when comparing two values", while "trailing spaces are semantically significant in character varying and text values". Both Snowflake mappings send the column to VARCHAR or TEXT, and Snowflake's CHAR is only "Synonymous with VARCHAR", so it does not pad either.
SELECT table_name, column_name, character_maximum_length
FROM information_schema.columns
WHERE data_type = 'character'
ORDER BY table_name;
How to read the result: Each row is a blank padded column whose equality semantics are about to change. For each one, run SELECT DISTINCT length(col) FROM tbl to see whether the values genuinely vary in length. Where they do, apply RTRIM on load and compare trimmed values on both sides, or a join on a padded product code will start returning fewer rows with no error anywhere.
array columns
One Snowflake document maps type[] to ARRAY with the note that a "strongly typed array transformed to ARRAY without type checking". The other does not list array types at all, and states "Any PostgreSQL data types not listed in this table are mapped to TEXT by default." So the same integer array becomes a semi-structured ARRAY under one Snowflake tool and a flat string under another.
SELECT table_name, column_name, udt_name
FROM information_schema.columns
WHERE data_type = 'ARRAY'
ORDER BY table_name;
How to read the result: The udt_name column tells you the element type, prefixed with an underscore, so _int4 is an integer array. Decide the target explicitly for every row rather than letting the tool default, and re-assert the element type in your first transformation, because neither Snowflake path preserves it.
replication slots that outlive their consumer
This is the only check here that protects production rather than the warehouse. PostgreSQL states replication slots "persist across crashes and know nothing about the state of their consumer(s). They will prevent removal of required resources even when there is no connection using them", and warns that "in extreme cases this could cause the database to shut down to prevent transaction ID wraparound".
SELECT slot_name, active, slot_type,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots
ORDER BY retained_wal DESC;
How to read the result: Any slot with active set to false and a growing retained_wal is an incident forming on your source database, not your pipeline. It happens every time somebody pauses or deletes a connector and leaves the slot behind. Alert on this figure before you alert on pipeline lag, and drop slots you no longer need.
Does Snowflake enforce primary keys and unique constraints?
No, not on standard tables. Snowflake accepts PRIMARY KEY, UNIQUE and FOREIGN KEY definitions, records them as metadata the query optimizer can use, and does not enforce any of them. Its constraints page is explicit that it does not enforce them "except for NOT NULL and CHECK constraints, which are always enforced". Hybrid tables do enforce the full set, which is one reason to consider them for the handful of tables where integrity genuinely matters.
This lands harder coming from Postgres than from anywhere else, because Postgres enforces the lot and most teams have leaned on it for years without thinking about it. The CHECK clause is the useful half of the sentence. It is what lets you rebuild the integer ranges the type collapse just removed, and it is enforced on ordinary tables with no special table type required.
Should I use NUMBER or FLOAT for money in Snowflake?
NUMBER, always. This is not a stylistic preference, it is what the source database tells you: PostgreSQL's money documentation states that "floating point numbers should not be used to handle money due to the potential for rounding errors". Snowflake's own Openflow connector nonetheless maps a Postgres money column to FLOAT by default, so unless somebody overrides it, currency arrives in your warehouse as a binary floating point approximation.
Use NUMBER(38,2) for ordinary currency, and cast on the source side with amount::numeric rather than letting the value pass through a float on its way out. While you are there, check the lc_monetary setting of the database you are reading from, because Postgres decides the fractional precision of the money type from it, and a dump restored into a differently configured database is a documented way to get this wrong.
What happens to my Postgres interval columns in Snowflake?
They become text. Snowflake states that "INTERVAL is not a data type (that is, you can't define a table column to be of data type INTERVAL). Intervals can only be used in date, time, and timestamp arithmetic." Both Snowflake mapping documents agree on this and both send the column to VARCHAR or TEXT, which is one of the few places on this route where they do not contradict each other.
The mapping is correct and the consequence is still yours to handle. Every query that added an interval to a timestamp, compared two durations, or summed elapsed time now operates on strings. The usable target is a NUMBER holding seconds plus a separate unit column, decided during the migration rather than discovered afterwards by an analyst whose report started returning nothing.
Why does my Snowflake join return fewer rows than Postgres?
Two causes account for nearly all of it on this route. The first is character(n) columns. Postgres treats trailing spaces on a character(n) value as semantically insignificant when comparing, and treats them as significant on varchar and text. Both Snowflake mappings send character(n) to a varchar-like type, so a padded product code that matched its unpadded twin in Postgres stops matching in Snowflake.
The second is case. Postgres collation is usually configured to be case sensitive already, so this route suffers less than the SQL Server one does, but any database created with a case insensitive ICU collation will behave differently in Snowflake. Normalise the join keys during the load, with RTRIM and where appropriate lower(), rather than wrapping every predicate in a function at query time and losing the pruning that makes Snowflake fast.
Do I even need to convert, now that Snowflake runs Postgres?
Worth asking, and almost nothing written about this route has caught up with it. Snowflake Postgres reached general availability on 24 February 2026 and lets you create and manage Postgres instances directly from Snowflake, each running on a dedicated virtual machine you connect to with any ordinary Postgres client. It is not a fork of Postgres, and it supports a curated extension set including PostGIS, pgvector, pg_cron and pg_stat_statements.
That does not make this page redundant, because the two products do different jobs. If you want columnar analytics over history, you still need the data in Snowflake tables and you still need every mapping decision above. If what you actually wanted was to stop operating Postgres yourself and to have one vendor, lift and shift is now a real option and it skips the type system entirely. Price both before committing to a conversion project, because the answer changed in February and most comparison content still assumes it did not.
Do it in this order
Inventory the types first, with a single query against information_schema.columns. Run the eight checks above while the source is still live and still authoritative. Write the target DDL by hand for the columns those checks flagged, which is usually under twenty columns out of several hundred, and let the tool generate the rest. Decide explicitly what happens to every unique index you are about to stop enforcing. Then load once, reconcile on values rather than counts, and only after that turn on continuous replication.
The full tool comparison for this route, including what each vendor bills by and the ten places the two Snowflake documents disagree, is on our Postgres to Snowflake migration tools page, and the connector itself is documented on the Postgres to Snowflake connector page. The mechanism underneath the ongoing sync is covered in Postgres logical replication. The equivalent treatment for the neighboring routes is in convert SQL Server data types to Snowflake and convert MySQL data types to PostgreSQL. Snowflake publishes a second pair of conflicting mappings for the Oracle route, taken apart the same way in Oracle to Snowflake data type mapping. If Postgres is staying alive alongside the warehouse, which it usually does, the ongoing case is in Snowflake ETL tools, and the budget model behind either program is in what a data migration really costs.
Once the data is in Snowflake, keep it in agreement with everything else
Map the fields once, run the backfill, then let the same mapping run incrementally with retries, alerts and per-record logs. From $49 a month, never metered by rows.
No credit card required.