BigQuery to Snowflake migration: how to move a warehouse without freezing reporting
10 min read Data engineering The Adapters team
Last updated July 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
Move from BigQuery to Snowflake one table at a time, not in a single cutover. Translate the
schema first (the type mapping is where numbers quietly go wrong), export history to cloud
storage in date-ranged chunks and COPY INTO Snowflake, then keep both warehouses
loading in parallel from the same upstream sources while you compare row counts, money-column
sums, and a few real dashboards. Flatten nested and repeated RECORD fields or
store them as VARIANT, rewrite the SQL Snowflake will not accept
(SAFE_CAST, UNNEST, APPROX_QUANTILES), and repoint each
report only after its table reconciles cleanly for several consecutive days.
Key takeaways
- A big-bang export freezes reporting. While you dump and reload everything at once, the numbers are stale, and every type bug waits until someone spots a wrong figure weeks later.
- Timestamps carry the nastiest trap. BigQuery
TIMESTAMPis a UTC instant whileDATETIMEis wall-clock with no zone, and mapping the wrong one toTIMESTAMP_TZorTIMESTAMP_NTZshifts hours silently. - Identifiers change case. BigQuery column names are case-sensitive and stored as declared; Snowflake uppercases unquoted names, so a mixed-case export becomes an inconsistent schema unless you standardize.
- Validate with numbers, not vibes. Row counts, sums of money columns, and min/max timestamps per table, plus a handful of dashboards checked side by side.
How do you migrate from BigQuery to Snowflake?
You migrate in four phases: translate the schema and types into Snowflake, backfill history in chunks through Google Cloud Storage, run both warehouses in parallel loading from the same upstream sources, and cut reports over per table once counts and totals reconcile. Rewrite BigQuery-specific SQL during the parallel window, never during the cutover itself.
Sequence matters more than tooling. Start by inventorying what you actually have: tables,
views, materialized views, scheduled queries, routines, and the IAM grants that gate them.
A large share of objects in most projects are stale, and migrating a table nobody queries
costs the same as migrating the one behind the executive dashboard. Pull usage from
INFORMATION_SCHEMA.JOBS and rank tables by real access over the last 90 days.
Everything below the line becomes an archive export instead of a migration target.
Then translate the DDL. BigQuery's schema JSON and INFORMATION_SCHEMA.COLUMNS
give you a starting point, but the output is not valid Snowflake, so treat it as input to a
mapping script rather than something to paste. Generate Snowflake DDL, decide where to keep
nested RECORD fields as VARIANT versus flattening them, and add
clustering keys where a table is large enough to need them. If your ingestion is already
managed, adding a second destination is a configuration change: the same source can feed a
BigQuery to Snowflake
migration path while the original loads keep running. Teams going the other way should
read the reverse
Snowflake to BigQuery guide, which covers the mirror-image traps.
Backfill history next, oldest partitions first, so a failure costs you a re-run of one month rather than four years. The extraction method decides how much of this you hand-build.
| Method | What it is | When it fits and what it costs you |
|---|---|---|
EXPORT DATA to GCS, then COPY INTO |
BigQuery writes Parquet or Avro to a bucket; Snowflake reads it from an external stage, optionally auto-ingested with Snowpipe | The workhorse for bulk backfill. Parquet and Avro keep types far better than CSV. You schedule, chunk, monitor, and retry the export yourself |
| BigQuery Storage Read API | A gRPC API that streams table data in Arrow or Avro directly, in parallel, without a query job | Fast for large extracts and avoids export file management. Needs code to consume the streams and land them into Snowflake staging |
| Scheduled query plus incremental watermark | A scheduled query selects rows since the last updated_at into a staging table you export and load |
Simple steady-state incremental sync. You own the watermark bookkeeping and the export step between the query and Snowflake |
| CDC via table snapshots or change history | BigQuery time-travel snapshots, or CHANGES and the _CHANGE_TYPE pseudo-column, expose inserts, updates, and deletes |
Captures deletes that a watermark alone never sees. More moving parts, and change history has a limited retention window you must load within |
| Managed sync tool | A hosted connector that handles export, staging, COPY, watermarks, MERGE, and schema drift |
When you would rather not own retries and chunking. Less control over the exact SQL issued; you accept the tool's semantics |
Most migrations combine two: a bulk EXPORT DATA or Storage Read backfill for
history, then an incremental watermark load for the steady state. Load into Snowflake
staging tables first, then MERGE into the targets on the primary key, so a
re-pulled window updates rows in place instead of doubling them. That idempotency matters
far more during a parallel run than in normal operation, because you will re-pull the same
window repeatedly while chasing a discrepancy.
How do BigQuery data types map to Snowflake?
Most BigQuery types have a direct Snowflake equivalent: STRING to
VARCHAR, BOOL to BOOLEAN, DATE to
DATE. The two that need judgment are the numeric family, where
BIGNUMERIC can exceed what Snowflake's NUMBER can hold, and the
timestamp family, where TIMESTAMP and DATETIME mean different
things and the wrong choice shifts hours without an error.
| BigQuery type | Snowflake type | Notes |
|---|---|---|
| STRING | VARCHAR | BigQuery STRING is unbounded UTF-8. Snowflake VARCHAR defaults to the max length, so you rarely need to declare one |
| INT64 | NUMBER(38,0) (alias INTEGER) | Snowflake INTEGER is NUMBER(38,0) underneath, wider than a 64-bit int, so every INT64 value fits with room to spare |
| FLOAT64 | FLOAT | Both are 64-bit IEEE floats. Never use for currency on either side |
| NUMERIC | NUMBER(38,9) | BigQuery NUMERIC is fixed at precision 38, scale 9. Map it exactly so money keeps its scale |
| BIGNUMERIC | NUMBER at declared precision | The trap. BIGNUMERIC allows up to precision 76, scale 38, which exceeds Snowflake NUMBER's ceiling of precision 38, scale 37. Values beyond 38 significant digits cannot be represented and must be rescaled or stored as text |
| BOOL | BOOLEAN | NULL stays NULL. Watch loaders that coerce empty strings to false |
| BYTES | BINARY | Export as base64 so bytes survive a text staging format, then Snowflake decodes on load |
| DATE | DATE | Direct match |
| DATETIME | TIMESTAMP_NTZ | DATETIME is wall-clock with no zone, which matches NTZ (no time zone). Do not send it to a zoned type, or you invent an offset that was never there |
| TIMESTAMP | TIMESTAMP_TZ or TIMESTAMP_LTZ | BigQuery TIMESTAMP is an absolute UTC instant. Map to a zoned Snowflake type. LTZ renders in the session zone; TZ retains an offset. Mapping it to NTZ drops the UTC anchor and shifts every row by the reader's offset |
| TIME | TIME | Both store time of day. Match the fractional-second precision so nothing truncates |
| GEOGRAPHY | GEOGRAPHY | Both use WGS84 spherical coordinates. Move through WKT or GeoJSON in staging |
| JSON | VARIANT (or OBJECT) | VARIANT keeps flexible semi-structured access with dot-path syntax. Use OBJECT when the shape is a known key-value map |
| STRUCT / RECORD | OBJECT (or VARIANT) | A single RECORD becomes an OBJECT. Store as VARIANT if the shape drifts, or flatten into typed columns if it is stable |
| ARRAY | ARRAY | Both support arrays. Snowflake arrays are semi-structured VARIANT elements rather than typed, so a homogeneous BigQuery array loses its element type unless you cast on read |
| Nested + repeated RECORD | Flatten to child table, or store as VARIANT | A repeated RECORD (an array of structs) has no native columnar twin. Either explode it into a child table keyed to the parent id, or keep the whole thing in one VARIANT column |
Identifiers are the other gotcha, and it surfaces late. BigQuery treats column and field
names as case-sensitive and stores them exactly as declared, so customerId and
customerid are two different columns. Snowflake folds unquoted identifiers to
uppercase, so customerId becomes CUSTOMERID unless you wrap it in
double quotes, and quoted names then have to be quoted forever. Pick one convention
(lowercase snake_case is the common choice), apply it in your DDL generator, and do not let
the export tool decide for you. Otherwise you get customer_id in some tables
and "customerId" in others, and every query written against the wrong spelling
fails or joins nothing.
How do you move nested and repeated fields from BigQuery to Snowflake?
You either flatten them into child tables during the load or store each nested column as a
single VARIANT. BigQuery models one-to-many relationships as repeated
RECORD fields that you read with UNNEST. Snowflake has no repeated
column type, so the query pattern changes: you expand arrays with LATERAL FLATTEN
instead.
The decision is about how the data gets queried, not just how it is stored. If analysts filter
and join on the nested values (order line items, event parameters, tags), flatten a repeated
RECORD into its own table keyed to the parent id during the load, so it becomes
ordinary SQL that Snowflake clusters and prunes efficiently. If the nested blob is read whole
or rarely, keep it as a VARIANT and expand on demand. A BigQuery query that reads
SELECT o.id, i.sku FROM orders o, UNNEST(o.items) AS i becomes, against a VARIANT
column in Snowflake, SELECT o.id, f.value:sku::string FROM orders o,
LATERAL FLATTEN(input => o.items) AS f. The shape is the same, the syntax is not,
and mixing them up is one of the most common rewrite errors.
SQL that breaks moving from BigQuery to Snowflake
Casting breaks first. BigQuery's SAFE_CAST, which returns NULL instead of erroring
on a bad value, becomes TRY_CAST in Snowflake. Approximate aggregates differ in
name and signature: APPROX_QUANTILES(x, 100)[OFFSET(50)] becomes
APPROX_PERCENTILE(x, 0.5), and APPROX_COUNT_DISTINCT maps to
APPROX_COUNT_DISTINCT with the same intent but its own tuning. ARRAY_AGG
exists on both sides, though Snowflake needs WITHIN GROUP (ORDER BY ...) for
ordered aggregation. BigQuery's SELECT * EXCEPT(col) and
SELECT * REPLACE(expr AS col) have no Snowflake equivalent, so those column lists
get written out. Quoting flips too: BigQuery wraps identifiers in backticks, Snowflake uses
double quotes, and string literals are single-quoted on both.
Physical design changes shape. BigQuery partitions a table by a DATE or
TIMESTAMP column or by ingestion time, and clusters on up to four columns.
Snowflake partitions automatically into micro-partitions and exposes clustering keys as an
optional tuning layer you rarely need to set until a table is large. So the explicit
partitioning you declared in BigQuery has no direct translation: you drop it and let Snowflake
micro-partition, adding a clustering key only where query pruning demands it.
How long does a BigQuery to Snowflake migration take?
For a mid-sized project (a few hundred tables and a handful of BI tools) plan on six to twelve weeks of calendar time, most of it spent in parallel validation rather than data movement. Copying bytes is the fast part. Rewriting SQL, reconciling numbers, and getting analysts to trust the new tables is what consumes the schedule.
A rough allocation that holds up: one to two weeks on inventory and schema translation, one to two weeks on backfill and load engineering, three to six weeks running both warehouses side by side while transformations and dashboards get ported, and a final week for cutover and decommissioning. Projects with heavy scheduled-query logic, JavaScript UDFs, or hundreds of hand-written dbt models sit at the long end.
The variable that actually moves the timeline is how much business logic lives inside BigQuery versus in a transformation layer you control. A dbt project ports quickly, because the models are text you can search, adapt, and test. Logic buried in scheduled queries, stored routines, or a pile of ad hoc views ports slowly, because someone has to rediscover what each one does before it can be replaced. A big-bang export tempts teams who want to skip the parallel run, but it freezes reporting for the duration and defers every type bug until a wrong number shows up in a board deck.
How do you validate a BigQuery to Snowflake migration?
You validate by running the same aggregates on both warehouses and comparing them mechanically, not by eyeballing a few rows. For every migrated table, check four things on a schedule: row count, the sum of each money or quantity column, the min and max of the primary timestamp, and the count of distinct primary keys. Only cut a table over once it reconciles cleanly for several consecutive days.
Run each aggregate over a fixed closed date range (all of last month, say) so late-arriving
data does not raise false alarms, and store the results in a small reconciliation table with
the run date. A column sum catches type problems that row counts miss entirely: if a
BIGNUMERIC got rescaled into a narrower NUMBER, the counts match
perfectly and the total is off by amounts that grow with volume. Spot-check a hash of a few
key columns per primary key to catch value-level drift a sum would net out. During the parallel
run it also pays to trace every table's lineage across both
warehouses, so a mismatch points at the exact upstream transform that diverged instead of
sending you hunting.
Then check real dashboards, not just tables. Pick five reports people actually open, point a
copy at Snowflake, and compare the rendered numbers for the same date filters. This is where
timezone mistakes surface, because a daily revenue chart drawn from a BigQuery
DATETIME that was mapped to a zoned Snowflake type looks fine in total but shifts
each day's bucket by a few hours, moving revenue across day boundaries. Keep both warehouses
loading from the same sources during this window so the comparison is apples to apples, and
leave the BigQuery loads running for two to four weeks after cutover as a rollback path: if a
report is wrong, you repoint it back and lose nothing. Suspend the BigQuery-side jobs only once
reconciliation has been quiet for a full cycle.
Is Snowflake cheaper than BigQuery?
Not inherently. The two bill on different axes: BigQuery on-demand charges for the bytes each query scans (or for reserved slot capacity), while Snowflake charges credits per second of warehouse uptime scaled by warehouse size. Whether you save money depends on your query shape, not on a rate card comparison.
The distinction has a practical consequence. In BigQuery on-demand, wall-clock time barely matters and bytes read are everything, so an unpartitioned table turns a cheap-looking dashboard into a full-history scan every refresh. In Snowflake, a query that scans a huge table costs the same as a small one if both finish in the same time on the same warehouse; your levers are warehouse sizing, auto-suspend, and concurrency. A workload tuned for BigQuery's bytes-scanned model is usually not tuned for Snowflake's per-second credit model, so plan to re-tune rather than copy: right-size warehouses, set aggressive auto-suspend, and let idle compute stop. Budget honestly for the overlap too, since running both warehouses through the validation window means paying for both until you switch the old one off. Set a decommissioning date before you start.
To map the pair for your own tables, set up the the BigQuery to Snowflake connector from the data integration platform. Our flat pricing is by plan rather than per row, so a heavy backfill month does not spike the bill, and you can book a walkthrough to see both warehouses loading in parallel before you commit to a cutover date.
Move BigQuery to Snowflake incrementally, with both warehouses loading in parallel
Type mapping, watermark loads, MERGE, and a per-run log you can reconcile against. Map the pair in the browser and pay a flat price from $49 a month.
No credit card required.