Skip to content

Stage, Then MERGE: How to Sync Salesforce With MotherDuck

A practical guide to syncing Salesforce with MotherDuck. It covers the connected app and OAuth flow on the Salesforce side, service token auth on the MotherDuck side, how to choose between a bulk export, SystemModstamp polling and Change Data Capture, mapping objects and picklists onto columnar tables, why a staging table plus a MERGE beats per-row updates, how to handle deletes and merged records, and how to prove the load is correct.

Author
Ruben Burdin · Founder & CEO
Published
July 23, 2026
Read time
10 min read
Stage, Then MERGE: How to Sync Salesforce With MotherDuck
DATA ENGINEERING

MotherDuck is a good place to put Salesforce data. It is DuckDB in the cloud, and a single query can run locally, in the cloud, or split across both. What it is not is a row-oriented database, which is exactly what most Salesforce pipelines were written against.

This guide walks through how to sync Salesforce with MotherDuck properly: authenticating both sides, choosing a change capture mechanism and living with its limits, mapping objects and picklists onto columnar tables, and applying each batch with a staging table and a MERGE instead of a stream of single-row updates. It also covers the two things hand-rolled scripts almost always get wrong, which are deletes and merged records.

Three decisions that keep a Salesforce to MotherDuck load correct: watermark on SystemModstamp, stage the batch and then MERGE, or let Stacksync run the capture and the write

If you are still choosing a platform rather than wiring one up, the companion post on an enterprise iPaaS for MotherDuck covers that decision. This one assumes it is made and you want the mechanics.

Why the CSV export stops working

Almost every MotherDuck and Salesforce project starts the same way. Somebody exports Accounts and Opportunities from a report, points read_csv_auto at the file, and has a queryable table in about four minutes. That is genuinely the fastest path to a first chart, and it is why so many teams stop there.

The trouble arrives in week two. An export is a snapshot, so last week's numbers are gone unless somebody kept the file. A rep renames a picklist value and the column quietly gains a new category. Two accounts get merged and the losing record Id stays in your table forever, double counting revenue. Nothing was ever deleted in MotherDuck, because a CSV cannot tell you what was deleted.

The second attempt is usually a Python script on a schedule: ask the Salesforce REST API for rows where LastModifiedDate is greater than the last run, then write them into MotherDuck. Closer, and it fails in more interesting ways. LastModifiedDate only moves when a user edits the record, so a lead conversion, a rollup recalculation or a cascading owner change modifies a row without touching it. SystemModstamp moves on both, which is why an incremental read should watermark on that column instead.

Then there is the write pattern. A script that loops over the batch and issues one UPDATE per record is fine against Postgres and falls over on a columnar store. The gap between those two shapes is wide enough that it gets its own post on two-way sync between MotherDuck and PostgreSQL, and the more general version of the argument lives in real-time sync versus batch ETL.

Authenticating both sides

Salesforce first. Server-to-server access goes through a connected app: create one in Setup, enable OAuth settings, add the api and refresh_token offline_access scopes, and keep the consumer key and secret somewhere the job can read them. For an unattended sync the JWT bearer flow with a certificate is the usual pick, because there is no browser in the loop and no password to rotate. Client credentials is simpler where your org allows it and you can nominate a run-as user.

Whichever flow you use, create a dedicated integration user rather than borrowing an admin login. Give it a permission set with API Enabled, read access to exactly the objects in scope, and write access only to the fields you intend to push back. It is boring work and it is the single control that stops a sync bug from becoming a data incident. The Salesforce connector page lists the permissions a managed platform asks for.

The MotherDuck side is shorter. Authentication is a service token: generate one, keep it out of the repository, and pass it as motherduck_token when the client opens md:my_database. There is no cluster to size and no VPC to open, which removes most of the network work a warehouse project carries. The tradeoff is that you connect through a DuckDB client rather than a generic JDBC or ODBC endpoint, so older ETL tooling often cannot point at MotherDuck at all. Check that before you plan around one.

Give the sync its own token and its own schema. Compute in MotherDuck is allocated per user, so a heavy load does not queue behind an analyst's dashboard query, and revoking a token later locks nobody else out. The MotherDuck connector page covers what the platform expects on that side.

Choosing how you read changes out of Salesforce

Salesforce gives you three realistic ways to learn what changed, and the choice sets the latency, the API cost and the failure modes of everything downstream.

Nightly bulk exportSystemModstamp pollingManaged sync (Stacksync)
LatencyA dayThe poll interval, 5 to 60 minutesSeconds
What it missesDeletes, merges, and every intra-day editDeletes, unless you query them separatelyNothing, deletes and merges included
Salesforce API costCheap per record, expensive in freshnessEvery poll re-reads records that did not changeOnly changed records move
Write pattern in MotherDuckFull table replaceWhatever the script does, often row by rowStaged batch, one MERGE
Recovery after an outageRerun the exportRewind the watermark and hope the window covers itReplay from the stored position
Who maintains itA cron job and a runbookYour engineers, indefinitelyConfiguration, not code

Three ways to learn what changed in Salesforce, and what each one costs.

The bulk export is the honest answer for a table that moves slowly and is read once a day. An Account object with 20,000 rows and no history requirement is fine as a full replace. Opportunity and Task are not, because the row count and the edit rate both keep growing.

Change Data Capture is what you want when freshness matters. Salesforce publishes record changes to an event channel, your subscriber holds a replay position, and it resumes from there after a disconnect. The limit worth writing on a whiteboard is retention: events stay on the bus for up to 72 hours, so a subscriber that has been down over a long weekend has to fall back to a SystemModstamp window or a fresh backfill. Design that fallback before the incident, not during it.

Either way, one cycle looks the same from the outside.

Sequence of one incremental cycle: Salesforce change events reach the sync engine, the batch is bulk inserted into a staging table, a MERGE applies it to the target table, and only then does the watermark advance before a scored field is written back
One incremental cycle between Salesforce and MotherDuck, with the watermark moving last.

The write-back leg at the end is optional but common: a churn score or a usage total computed in MotherDuck and pushed onto the Account record. The moment you add it you are running a round trip, and every write needs an origin tag so the value you just wrote is not read back a second later as a fresh change. Bidirectional sync explained shows what that looks like on real records.

Mapping Salesforce objects onto columnar tables

One object becomes one table. Keep the 18 character record Id as the key column and use it as the join key everywhere, because it is what every later MERGE matches on. Resist the urge to invent a surrogate key, because you will only have to map it back later.

Types need decisions rather than defaults. These are the ones that are cheap now and expensive after the first six months of history.

Salesforce fieldMotherDuck columnThe part that bites
Id, lookups, master-detailVARCHARTwo forms exist, 15 and 18 characters. Pick 18 everywhere or joins miss silently.
PicklistVARCHAR plus a lookup tableValues get renamed and deactivated. Store the API name, not the label, or history rewrites itself.
Multi-select picklistVARCHAR[]Salesforce ships them semicolon delimited. Split on load or every filter becomes a LIKE.
CurrencyDECIMAL(18,2)Multi-currency orgs also carry CurrencyIsoCode and a dated rate. Store both or the totals will not tie out.
Date, DatetimeDATE, TIMESTAMPThey arrive in UTC. Convert at query time, never on load.
Formula, rollupMaterialized columnComputed on read, so a change event does not always announce that they moved.
Custom __c fieldsSnake case columnAdmins rename things. Map by API name and keep a rename map.

Type decisions that are cheap to make now and painful to change later.

Formula and rollup fields deserve a second look. A change event on the child record that feeds a rollup does not reliably announce that the parent's value moved. If a formula field matters for reporting, either recompute it in MotherDuck from the underlying columns or refresh the parent on a schedule. Treating it as a stored column is a slow, quiet source of drift.

Start narrow. Account, Contact and Opportunity cover most first projects, and adding an object later costs an afternoon. Selecting every object in the org costs you a backfill nobody asked for and a schema nobody understands. The MotherDuck and Salesforce integration page shows which objects the connector exposes.

Stage the batch, then MERGE

This is the part that separates a load which stays fast from one that gets slower every week. DuckDB, and therefore MotherDuck, stores data column by column in compressed row groups of roughly 120,000 rows. An update is not an in-place edit of one value, it rewrites the parts it touches. Issue 5,000 single-row UPDATE statements and you pay that cost 5,000 times, plus 5,000 round trips.

The pattern that works is the one warehouses have used for years, and it is three steps.

Five stages of the Salesforce to MotherDuck write path: read only what changed, buffer into one batch, land a staging table, MERGE on the record Id, then move the watermark last
Read narrow, batch wide, apply once. The watermark moves only after the MERGE commits.
  • Load a staging table. One bulk insert of the batch into stg_account, with no constraints and no indexes to maintain. Rebuild it every cycle rather than appending to it.
  • Apply it in one statement. MERGE INTO account USING stg_account ON account.id = stg_account.id where your DuckDB version supports it, or INSERT INTO account SELECT * FROM stg_account ON CONFLICT (id) DO UPDATE SET ... when the target carries a primary key on the Id. Either way the whole batch lands as one operation.
  • Move the watermark last. Record the new high water mark only after that statement commits. If the job dies halfway you replay the window instead of losing it.

Batch size is a tuning knob, not a constant. A few thousand rows per statement is a sensible start: much smaller and you pay per-statement overhead again, much larger and a failure costs a bigger replay.

If you are also pushing values back into Salesforce, batch that side too. Salesforce meters API requests per org over a rolling 24 hours, and the allocation depends on edition and licence count, so a write-back that fires one call per record is the fastest way to find the ceiling. The arithmetic is worked through in the guide to syncing Salesforce without hitting API limits.

Book a Stacksync demo: keep Salesforce objects and MotherDuck tables in real-time two-way sync

Deletes, merges, backfill, and proving it worked

Deletes are where hand-rolled pipelines quietly diverge. A SystemModstamp window returns rows that changed; it does not return rows that stopped existing. queryAll() with IsDeleted = true finds them while they are still in the Recycle Bin, which holds records for 15 days. Miss that window and the only way to find the gap is a full key comparison against the object.

Prefer a soft delete: an is_deleted flag and a deleted_at timestamp rather than removing the row, because analysts usually want to know that an opportunity existed and then went away. Hard deletes also fragment a columnar table for no benefit.

Record merges are the same problem wearing a different hat. When two accounts are merged, the losing record is deleted and its MasterRecordId points at the survivor. If nothing follows that pointer, your table keeps both rows, every count is off by the number of merges, and nothing in the data looks wrong. Capture MasterRecordId and repoint the child rows in the same batch.

The backfill is a separate job from the incremental one. Use Bulk API 2.0, which chunks large extracts for you, run it one object at a time, and note the SystemModstamp value at the moment it starts so the incremental job picks up exactly where it stopped. Let it finish and reconcile before you enable live sync, otherwise you will run it twice.

Validation is an afternoon well spent. Compare row counts per object against a SELECT COUNT() in Salesforce, edit twenty random records in the CRM and confirm the new values arrive, then sum a currency field on both sides and check that it ties. Then break something on purpose: pause the sync, make a few edits, restart it, and confirm the window replayed rather than skipped. Alarm on API usage as a share of the allocation, on error counts, and on watermark lag.

Getting it running

Syncing Salesforce with MotherDuck means respecting two constraints at once. Salesforce decides what you can learn about a change and how often you may ask; MotherDuck decides how you write it down. Watermark on SystemModstamp, stage every batch and apply it with one statement, handle deletes and merges explicitly, and the rest is maintenance.

The alternative to building it is not building it. Stacksync reads Salesforce change events, maps the objects and picklists, applies each batch as a staged MERGE, and handles the return leg under one conflict policy. To see it against a real org, book a demo, read the MotherDuck connector announcement, or take the companion guides on an enterprise iPaaS for MotherDuck and two-way sync between MotherDuck and PostgreSQL. If the warehouse choice is still open, MotherDuck and Snowflake and MotherDuck and PostgreSQL are the two comparisons that come up most often.

Start syncing Salesforce with MotherDuck both ways, without writing a loader

FAQ

Frequently asked questions

How do I sync Salesforce with MotherDuck?
Create a connected app in Salesforce with OAuth enabled and the api scope, then authenticate an integration user through the JWT bearer or client credentials flow. On the MotherDuck side, generate a service token and pass it as motherduck_token when the client opens the database. Map each Salesforce object to a table keyed by the 18 character record Id, run one backfill with Bulk API 2.0, then read changes incrementally through Change Data Capture or a SystemModstamp window. Apply every batch by loading a staging table and running a single MERGE against the target, and move the watermark only after that MERGE commits.
Why should I watermark on SystemModstamp instead of LastModifiedDate?
LastModifiedDate only moves when a user edits the record. System-level changes such as a lead conversion, a rollup recalculation or a cascading owner change can modify a record without touching it, so an incremental read that filters on LastModifiedDate silently skips those rows. SystemModstamp moves on both user and system changes, which makes it the only timestamp an incremental read can trust. Store the high water mark yourself and advance it only after the batch has been written, so a failed run replays the window instead of losing it.
Why is a per-row UPDATE bad in MotherDuck?
MotherDuck runs DuckDB, which stores data column by column in compressed row groups of roughly 120,000 rows. An update is not an in-place edit of a single value: it rewrites the parts it touches. Five thousand single-row UPDATE statements pay that cost five thousand times, plus five thousand round trips. The pattern that stays fast is to bulk load the batch into a staging table with no constraints, then apply it in one statement with MERGE INTO, or with INSERT ... ON CONFLICT DO UPDATE when the target has a primary key on the Salesforce Id.
How do I handle Salesforce deletes and merged records in a warehouse?
Neither shows up in a normal incremental query. For deletes, use queryAll() with IsDeleted = true, which returns records while they are still in the Recycle Bin, and Salesforce keeps them there for 15 days. Set an is_deleted flag and a deleted_at timestamp in MotherDuck rather than removing the row, so analysts can still see that the record existed. For merges, the losing record is deleted and its MasterRecordId points at the survivor, so capture that field and repoint the child rows. Skip either one and your counts drift with no visible error.
Can Salesforce data land in MotherDuck in real time?
Close to it. Salesforce Change Data Capture publishes record changes to an event channel that a subscriber reads with a stored replay position, so a change can reach MotherDuck within seconds rather than at the end of a batch window. The constraint to plan around is retention: events stay on the bus for up to 72 hours, so a subscriber that has been offline for a long weekend has to fall back to a SystemModstamp window or a fresh backfill. Build that fallback path before you rely on the stream.
Do I still need a CSV export or a reverse ETL tool alongside this?
No, and running both is how teams end up with two schedules and two failure modes. A CSV export is a snapshot with no deletes, no merge handling and no history, which is fine for a first chart and wrong as a foundation. A reverse ETL tool covers only the return leg, so you maintain one pipeline out of Salesforce and a second one back into it with no shared conflict policy. A platform that reads change events, applies the staged MERGE and writes back under one policy replaces both.

About the author

Ruben Burdin
Ruben Burdin
Founder & CEO

Ruben Burdin is the Founder and CEO of Stacksync, the first real-time and two-way sync for enterprise data at scale. Ruben is a Y Combinator alumni with a strong background in software engineering and business.

All posts by Ruben Burdin

About Stacksync

Stacksync powers real-time, two-way sync between CRMs, ERPs, and databases. Engineers sync data at scale and automate workflows, not dirty API plumbing.

Coworkers laughing in front of a laptop in a casual office setting

Your last integration took months.
Your next one takes a prompt.