Estuary

Salesforce to Snowflake: API Sync, Replication & Data Loading Guide

Compare Salesforce to Snowflake methods, including Bulk API polling, Openflow, Change Data Capture events, zero-copy sharing, and Estuary, with setup steps, API limit math, and fixes for deletes and formula fields.

Salesforce to Snowflake integration guide cover showing the Salesforce and Snowflake logos
Share this article

Salesforce data reaches Snowflake through a one-time export, scheduled API polling (self-built or managed), Snowflake's Openflow connector, Change Data Capture events, or zero-copy sharing for Salesforce Data Cloud customers. Salesforce exposes no database log, so every method uses its APIs. For many objects kept current, the usual pattern loads each object with Bulk API 2.0, polls queryAll for records whose SystemModstamp moved, and merges them into Snowflake on Id.

The hard parts are the org's daily API allocation, which Bulk API 2.0 also draws on, and changes a timestamp cursor handles badly: formula values and deletes. Estuary is one managed option for this route: its Salesforce connector backfills through Bulk API 2.0 where it can, polls REST queryAll for changes on a per-object interval, and loads Snowflake on a configurable schedule.

Choosing a Salesforce to Snowflake method

ApproachBest fitFreshnessOperational burdenExample implementation
One-time exportMigration or snapshotPoint in timeLow, manualData Loader or a Bulk API 2.0 job → COPY INTO
Polling you buildA few objects, an existing orchestratorAt the scheduleHigh: cursors, MERGE, formula re-readsAirflow or cron calling Bulk API 2.0 and REST
Snowflake-native connectorSnowflake-only, standard login hostEvery 1 minute to 24 hoursMedium: Openflow runtimeOpenflow Connector for Salesforce Bulk API
Change Data Capture eventsEvent-level reaction on a few objectsPushed to a subscriber; Snowflake still batch-loadsHigh: subscriber, backfill, merge layerPub/Sub API client
Zero-copy sharingSalesforce Data Cloud customersShared, not copiedLow, given Data CloudData Cloud zero-copy integration
Managed polling connectorMany objects, no pipeline codePoll interval plus load scheduleLow: configurationEstuary

Three of these methods poll Salesforce, so most of this guide is about polling well.

How Salesforce to Snowflake API replication works

A poller loads each object once, then repeatedly fetches records changed since a saved cursor and merges them by Id.

  • Initial load: a Bulk API 2.0 query job per object, which Salesforce recommends above 2,000 records. Bulk jobs can't select compound address or geolocation fields.
  • Cursor:SystemModstamp, which moves on user and automated updates. LastModifiedDate reflects only users.
  • Change sweeps: REST queryAll over a SystemModstamp window, up to 2,000 records per response, including deleted and merged records. Below 2,000 records, one call beats a Bulk job's submit, poll and download requests. Changes are then staged and merged into a Snowflake table per object.
A Salesforce Bulk API load and queryAll sweeps merged by Id into Snowflake.
After the initial load, every change, including deleted and merged records, arrives through cursor sweeps.

Salesforce requirements

The daily API allocation belongs to the org and is shared by every integration and installed managed package:

EditionDaily API requests
Enterprise, or Professional with API access100,000 + 1,000 per Salesforce license
Unlimited, Performance100,000 + 5,000 per Salesforce license
Developer15,000

Bulk API 2.0 requests count toward it, and query jobs have their own caps: 10,000 jobs and 1 TB of results per rolling 24 hours.

The integration user needs:

  • API Enabled, from its profile or a permission set.
  • Read on each object and field-level security on each field. API clients see only what the user can.
  • View All Data to replicate every record, because API calls respect the sharing model.
  • Your My Domain host for sign-in, such as mycompany.my.salesforce.com, if login policy blocks login.salesforce.com.

Snowflake requirements

Give the pipeline a database, an X-Small warehouse, a key-pair service user (Snowflake is phasing out passwords for service users) and one schema per Salesforce org, so sandbox data never sits beside production.

  • GROUP and ORDER are reserved in Snowflake and ACCOUNT, CASE and ORGANIZATION restricted, so quote those table names, which makes them case-sensitive ("Case"). Keep QUOTED_IDENTIFIERS_IGNORE_CASE at FALSE so they resolve.
  • Salesforce date/time values are UTC, so TIMESTAMP_LTZ or a UTC-normalized TIMESTAMP_NTZ loses nothing.
  • AUTO_SUSPEND = 60 bills each load about a minute of warehouse time, not the ten minutes the default leaves it running.

Setting up Salesforce to Snowflake replication

  1. Create the integration user, and check headroom in the REST /limits resource (DailyApiRequests, DailyBulkV2QueryJobs).
  2. Prepare Snowflake and choose objects, including the parents your joins need.
  3. Load existing data with a Bulk API 2.0 queryAll job per object, paging large objects by Id (WHERE Id > :last_id ORDER BY Id LIMIT n).
  4. Sweep changes over a closed window that ends a few minutes before now, and merge on Id:
sql
SELECT Id, Name, StageName, Amount, IsDeleted, SystemModstamp FROM Opportunity WHERE SystemModstamp > 2026-10-06T10:00:00Z AND SystemModstamp <= 2026-10-06T10:25:00Z ORDER BY SystemModstamp ASC
  1. Schedule formula re-reads, and pick a load interval that matches how fresh reports really need to be.
  2. Validate. Compare record counts. Edit a record, delete one, add a custom field, and change a parent value behind a cross-object formula; check each after one load (the formula after its re-read). After a day, compare API usage with the allocation.

What happens after the initial load?

  • Updates, automated ones included, merge by Id.
  • Deletes and merges:queryAll returns deleted records and the losing records of merges with IsDeleted = true, plus archived Task and Event records. The rows stay in Snowflake unless the pipeline removes them.
  • Purged records vanish from every API. The Recycle Bin keeps records for 15 days, up to 5,000 per license; when it's full, Salesforce purges the oldest that have been there two hours, so an infrequent poll can miss a mass delete.
  • Formula fields are calculated on read. When a formula reads a parent record, editing the parent moves the parent's SystemModstamp, not the child's, so Snowflake keeps the child's stale value. Re-read formula columns on a schedule, or rebuild simple formulas as Snowflake views.
  • New custom fields arrive once the object is re-described and the user can see them. Records loaded before the field existed keep NULL there until the object is re-read.

Salesforce to Snowflake data type mapping

Salesforce fieldAPI typeSnowflake targetNotes
Id, Lookup, Master-DetailIDVARCHAR
Multi-select pickliststringVARCHARSemicolon-delimited; split with SPLIT_TO_TABLE
CheckboxbooleanBOOLEAN
Currency, Percent, decimal NumberdoubleNUMBER(p,s)FLOAT if mapped by API type
Date, Date/Time, Timedate, dateTime, timeDATE, TIMESTAMP_*, TIME or VARCHARDate/Time values are UTC
Address, Geolocationcompound Address, LocationVARIANT, or the component columns

Currency and percent fields arriving as FLOAT

Salesforce exposes currency, percent and decimal number fields as double, so type-mapping loaders create FLOAT columns, which hold about 15 significant digits: totals pick up floating-point error, and long values round. Describe reports each field's precision and scale, so cast to NUMBER(precision, scale) in a view or at load. Bulk API 2.0 returns CSV, and a loader with no type mapping makes every column VARCHAR.

Performance, latency and Snowflake cost

A Salesforce change waits up to a poll interval, any safety lag and a Snowflake load interval. Polls spend API requests; loads spend warehouse credits, and Snowflake bills at least a minute each time a load wakes the warehouse, so loading every few minutes means it never suspends. Neither makes Snowflake tables real time: even Change Data Capture events, pushed as changes happen, wait for the next load.

Where the API requests go

A poller's floor is objects × polls per day × requests per poll. Twenty objects polled every 5 minutes, with one describe and one query each, use 11,520 requests a day, a tenth of an Enterprise org with 15 licenses (115,000). Add extra queries for wide objects (REST caps the URI and headers at 16,384 bytes), a request per further 2,000-record page, every Bulk submission, status check and result page, and formula re-reads of every record.

To spend less, poll only as often as you need, sync only objects you query, hide unused fields from the integration user, and re-read formulas daily. Usage shows in System Overview, the Sforce-Limit-Info header and /limits.

Production considerations

  • Recovery. A restarted poller resumes from its cursor, and the merge absorbs the overlap. Rows for records purged during a long outage stay until you compare Id lists.
  • Backfills. A re-load spends allocation and Bulk jobs again unless the pipeline keeps its own copy, and a merge-based re-load never removes rows for purged records.
  • Monitoring. Alert on API usage, cursor age per object, failed Bulk jobs and time since the last Snowflake load.
  • Security. One read-only integration user per pipeline, and key-pair auth on Snowflake.

Common problems and troubleshooting

Fields or records missing in Snowflake

Run the object's describe (GET /services/data/vXX.X/sobjects/Account/describe) as the integration user. A field absent from the response is hidden by field-level security (Set Field-Level Security in Object Manager). Short row counts point at sharing: grant View All Data, then re-load the object, since the cursor has already passed those records.

Salesforce rejects ContentDocumentLink queries that don't filter on Id, LinkedEntityId or ContentDocumentId, and a replication sweep filters on SystemModstamp. From API version 59.0, the Query All Files permission, which requires View All Data, lifts the rule. Otherwise leave the object out of the sync.

Millions of records with one SystemModstamp

A mass update stamps millions of records with one instant. OFFSET paging fails past 2,000 rows (NUMBER_OUTSIDE_VALID_RANGE), and a cursor saved in the middle of a tie skips the rest of it after a restart. Page by Id inside the window, follow nextRecordsUrl instead of OFFSET, and advance the cursor only when the timestamp changes.

Bulk API daily caps reached

Jobs fail with Max bulk v2 query jobs (10000) per 24 hrs has been reached or, past 1 TB of results, Max bulk v2 query result size stored (1000000000) kb per 24 hrs has been exceeded. Both windows roll over 24 hours (DailyBulkV2QueryJobs in /limits). Load urgent objects over REST meanwhile.

Using Estuary for Salesforce to Snowflake

The Salesforce capture connector feeds the Snowflake materialization: each object becomes a collection keyed on /Id, and each collection merges into its own Snowflake table.

  • API usage: sweeps always use REST queryAll; each object, every interval (5 minutes by default), costs one describe plus one query per field chunk. Backfills use Bulk API 2.0, except for objects with compound address, geolocation or base64 fields (Account, User and others), which backfill over REST.
  • Deletes: a delete arrives as an update with IsDeleted = true. The row stays, and Hard Delete doesn't remove it, so filter with WHERE NOT ISDELETED. Records purged from the Recycle Bin before a sweep keep IsDeleted false.
  • Formula fields: a per-binding cron (default 23:55 UTC daily) re-reads them and merges them into stored records. Keep standard updates: a delta-update table gets each refresh as a row of NULLs around the formula values.
  • Types: custom fields are captured as strings, since Salesforce's describe often mistypes them: custom numbers become FLOAT or INTEGER from a format hint, custom checkboxes TEXT. Standard currency and percent become FLOAT, and compound addresses VARIANT beside their component columns. Map collections to tables below shows how to keep amounts exact.
  • Limits: standard objects come from an internal list (support adds more); external (__x) and big (__b) objects aren't discovered. Loads follow the sync schedule, 30 minutes by default.

Implementing Salesforce to Snowflake with Estuary

These steps adapt Estuary's general data flow guide to this route.

  1. Check the prerequisites. The Salesforce and Snowflake requirements above apply; for Estuary you also need:
  2. Create the Salesforce capture. In Sources, New Capture opens the connector list; choose Salesforce, give the capture a name, and complete these endpoint properties:
    • Authentication: OAuth through Authenticate your Salesforce account; a username, password and security token, which fails under enforced SSO; or Client Credentials with a Consumer Key and Consumer Secret.
    • My Domain: your login host, such as mycompany.my.salesforce.com. Client Credentials requires it.
    • Sandbox: on for a sandbox org.
    • Start Date: skips records whose cursor timestamp, usually SystemModstamp, is older.
Salesforce capture form with OAuth selected, My Domain, Sandbox and Start Date.
  1. Select objects. Click Next. Output Collections lists the supported objects, with Account, Contact, Lead, Opportunity, Case, Task, User and others enabled; add what else you need, such as OpportunityLineItem or custom __c objects. Per binding:
    • Interval: the poll interval, PT5M by default.
    • Formula Field Refresh Schedule: a UTC cron, 55 23 * * * by default. Clear it where formulas don't matter.

Click Save and publish. Each enabled object's backfill begins, and its change sweeps run in parallel from the start.

  1. Create the Snowflake materialization. The published capture's page has a Materialize button; choose Snowflake from the connector list, then complete these endpoint properties:
    • Host (Account URL) plus the Database, Schema, Warehouse and Role you created for this org.
    • Snowflake Timestamp Type: no default; TIMESTAMP_NTZ (normalize to UTC) or TIMESTAMP_LTZ keeps Salesforce's UTC times.
    • Authentication: Private Key (JWT), entering the service user as User and its unencrypted PKCS#8 key as Private Key. The User Password tab is deprecated.
    • Hard Delete: leave off; it ignores IsDeleted updates.
Snowflake endpoint form with the Snowflake Timestamp Type dropdown open.
  1. Set the load schedule.Sync Frequency, under Sync Schedule, is 30 minutes by default and sets how often the warehouse wakes; the binding Interval sets how often Salesforce is polled. If half-hour-old data is fine, a PT30MInterval needs a sixth of the default's sweeps. A fast-sync window (Timezone, Fast Sync Start Time, Fast Sync Stop Time, Fast Sync Enabled Days) keeps the short frequency to business hours, with 4-hour loads outside. See sync schedules.
Snowflake Sync Schedule section with the Sync Frequency dropdown open.
  1. Map collections to tables. Click Next, pick a naming convention, and leave Delta Updates off for each binding under Source Collections. Reserved-word objects get quoted names, such as "Account". To keep currency and percent fields exact, add a DDL override that matches the field's describe precision and scale under its binding's fields.include in the specification editor before publishing, such as Amount: { DDL: "NUMBER(18,2)" } (custom column types); it applies when the table is created. Custom number fields arrive as the text Salesforce sent, so castToString: true keeps those digits exactly in a TEXT column.
  2. Publish. Click Save and publish. Estuary creates a table per object, and the backfill lands immediately, because backfills ignore the sync schedule.
  3. Check the first loads.
    • The capture details page reads PRIMARY in its Shard Information card; open an object's collection from the Collections list to see records in its Data Preview.
    • On the materialization details page, Data Read rises as backfills land.
    • In Snowflake, compare SELECT COUNT(*) FROM "Account" WHERE NOT ISDELETED with Salesforce, then run the validation checks.
Snowflake materialization Overview: Connector Status Running and a Data Read chart.

How Estuary handles Salesforce API budgets, formula refreshes and rebuilds

Id-paged backfills and tie-safe sweeps

Since July 2026, backfills page each object by Id, so their speed no longer depends on how records spread over time. Each page is one Bulk job of up to 500,000 records, downloaded 25,000 per request; a page Salesforce rejects (compound fields, an unsupported object, a daily cap) moves to REST instead of failing. Sweeps stop 5 minutes short of now, so late-visible records aren't skipped, and the cursor advances only to a later timestamp, so ties survive restarts.

Formula refreshes as partial documents

A refresh selects only Id, the cursor and formula fields, skips objects without formulas, and merges each result into the stored record, so other columns keep their values. Each run still re-reads every record, in Bulk jobs that count toward the daily caps.

Rebuilding Snowflake without calling Salesforce

Each object's records persist in a collection, written as JSON files to your own cloud storage bucket. A materialization backfill reloads Snowflake tables from those files, and a second destination reads them too, with no Salesforce requests. A capture re-read spends requests and can't remove rows for records purged meanwhile, because incremental objects never emit deletes. Estuary's trial buckets drop data after about 20 days, so point collections at your own bucket to keep full history.

When another approach may make more sense

  • Snowflake-only and native: the Openflow Connector for Salesforce Bulk API refreshes every 1 minute to 24 hours by SystemModstamp. It ignores address and location fields, doesn't support custom Salesforce domains, turns formulas into Snowflake views, and only soft-deletes (isDeleted).
  • Salesforce Data Cloud customers: Snowflake recommends its zero-copy integration.
  • Event-level reaction on a few objects: Change Data Capture through the Pub/Sub API. By default an org selects up to 5 entities and gets 25,000 event deliveries per 24 hours on Enterprise (50,000 on Unlimited and Performance), shared with high-volume platform events and counted per subscriber. Events are kept 72 hours and updates leave unchanged fields empty, so you still build a backfill and a merge layer. Estuary's connector polls and doesn't cover this.
  • One migration, or a few objects nightly: a Bulk API 2.0 export with COPY INTO and MERGE, re-reading whole objects each run. Estuary's connector can also poll on a long Interval.

To try it, follow Estuary's data flow guide or create a Salesforce capture in the Estuary dashboard. Pricing: estuary.dev/pricing.

FAQs

    How do Salesforce lookup and master-detail relationships appear in Snowflake?

    As Id columns on the child, such as Opportunity.AccountId. Join them in views, and replicate the parents too. Polymorphic lookups need a join per parent type: Task.WhoId holds a Contact or a Lead ID.
    Yes. History objects such as AccountHistory, OpportunityFieldHistory and custom __History objects hold a row per recorded change, with Field, OldValue and NewValue. They have no update call, so CreatedDate works as the cursor. OldValue and NewValue can hold any type, so Estuary lands them as VARIANT.
    Land each source in its own schema of one database and join in views on a shared key, such as a customer number stored on Account. In Estuary, one Snowflake materialization can take collections from several captures.

Start streaming your data for free

Build a pipeline

About the author

Picture of Seán Whelan
Seán WhelanSolutions Engineer

Seán Whelan is a Solutions Engineer at Estuary, helping customers design, troubleshoot, and run production data pipelines. Seán works across change data capture, database replication, and SaaS integrations, and writes about what actually comes up when real teams move data.

Real-Time & Batch Pipelines.Simple to Deploy.Simply Priced.

  • $0.50/GB of data moved + $100/month per connector instance
  • Simple usage-based pricing with volume discounts
  • Sub-100ms end-to-end latency for real-time pipelines