Salesforce to Postgres: how to replicate CRM data into a database your apps can query
9 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
To replicate Salesforce into Postgres, read the objects you care about (Account, Contact, Lead,
Opportunity, and your custom __c objects) through the REST query API, using Bulk API 2.0
for the first large backfill, filter each pull on SystemModstamp so you only fetch changed
records, and upsert into Postgres with INSERT ... ON CONFLICT (sf_id) DO UPDATE keyed on
the 18-character Salesforce Id. Store the max SystemModstamp as a watermark, and your apps
query the copy instead of burning the org's daily API cap.
Key takeaways
- Backfill with Bulk API 2.0, run steady-state on the query API. Bulk jobs return CSV batches for the big initial load, then the watermark keeps daily pulls small.
- Upsert on the 18-character Id.
ON CONFLICT (sf_id) DO UPDATEmakes a retry update in place instead of doubling rows. - Watermark on SystemModstamp, not LastModifiedDate. SystemModstamp also moves on system and trigger updates, so nothing is missed. Overlap the window a few minutes.
- Money is NUMERIC, deletes need getDeleted. Never store currency as FLOAT, and poll getUpdated/getDeleted so the replica drops rows Salesforce already removed.
How do you connect Salesforce to Postgres?
You connect Salesforce to Postgres by reading Salesforce objects through an API and writing them into Postgres tables, because Salesforce has no native Postgres destination. Something sits in the middle: a managed connector, or a scheduled script that calls the REST query API (or Bulk API 2.0 for volume) and lands the results. Every read counts against the org's daily API call limit.
Salesforce exposes three practical access paths. The REST query API runs SOQL and returns JSON,
which is the workhorse for incremental pulls. Bulk API 2.0 submits an asynchronous job and returns
CSV batches, which is the right tool for the initial backfill of large objects like Opportunity or
a busy custom object. SOAP web services add the getUpdated and getDeleted
calls that no REST query surfaces cleanly. The managed path is the
Salesforce to Postgres integration,
which owns the paging, the watermark, the staging table, the upsert, and the retries.
| Extraction method | What it is | When to use |
|---|---|---|
| REST query API (SOQL) | Synchronous SOQL queries that return JSON, paged with queryLocator | Steady-state incremental reads on the SystemModstamp watermark. The default |
| Bulk API 2.0 | Asynchronous jobs that return large result sets as CSV batches | The initial backfill of large objects, or any full reload of millions of rows |
| SOAP getUpdated / getDeleted | Web-service calls that return Ids changed or deleted in a time window | Catching hard deletes so the replica drops rows Salesforce already removed |
Practically, the connection has three parts. First, a connected app in Salesforce with an OAuth client and a least-privilege integration user that can read the objects you replicate. Second, a Postgres landing spot: a database, a schema, and a role that can create tables and run upserts. Third, the transfer itself, which pages through each object, lands each batch in a staging table, and upserts into the target. If your reporting lives in a warehouse instead of an application database, the sibling path is Salesforce to Snowflake.
How do you extract Salesforce data incrementally?
Extract incrementally by filtering each SOQL query on SystemModstamp, storing the
maximum value you saw as a watermark, and resuming from it on the next run. Prefer
SystemModstamp over LastModifiedDate because it also advances on system
and trigger-driven updates. Page with a stable ORDER BY Id, and overlap the window a
few minutes so out-of-order commits are never skipped.
The two timestamps look interchangeable but are not. LastModifiedDate reflects
user-visible edits, while SystemModstamp also moves when a workflow, a trigger, or a
system process touches the record. If you watermark on LastModifiedDate, records
changed only by automation slip past your filter and the replica silently drifts. A typical run
reads the stored watermark, issues SELECT Id, ... FROM Opportunity WHERE SystemModstamp >=
:watermark ORDER BY Id, walks the pages until the batch is drained, and then advances the
watermark to the max SystemModstamp returned.
For the very first load, do not page the REST query API through millions of rows. Submit a Bulk API 2.0 job per large object, pull the CSV batches, and load them once. After the backfill lands, flip to the REST query API on the watermark for every run after that. Overlap protects correctness: subtract a few minutes from the watermark before you query, because a record can commit with a modstamp slightly behind one that already loaded. Re-reading a handful of rows costs almost nothing when the load side is idempotent, which is the next concern.
How do Salesforce fields map to Postgres types?
Map the Salesforce Id to a TEXT primary key, currency and number fields to
NUMERIC (never FLOAT), date to date and datetime to
timestamptz in UTC, checkbox to BOOLEAN, picklist to TEXT,
lookup and master-detail references to a foreign-key TEXT column, and long text area to
TEXT. Always store the 18-character case-safe Id, not the 15-character one.
| Salesforce field / type | Postgres type | Notes |
|---|---|---|
| Id (18-character) | TEXT (primary key) | Case-safe. The 15-character Id is case-sensitive, so store the 18-character form |
| currency / number | NUMERIC | Never FLOAT for money. Exact decimals keep ledger sums honest |
| date / datetime | date / timestamptz | Store datetime as timestamptz in UTC. Salesforce returns datetimes in UTC already |
| checkbox | BOOLEAN | Cast the true/false value to a real boolean so filters read cleanly |
| picklist | TEXT | Store the API value. Add a CHECK or a lookup table if you want validation |
| lookup / master-detail (reference) | FK column (TEXT) | Holds the related record's Id. Join to its own replicated table |
Two details cause silent bugs. First, money as FLOAT introduces rounding drift that
shows up when you sum a pipeline, so use NUMERIC and let Postgres keep exact decimals.
Second, formula fields recompute inside Salesforce and are not stored as plain columns you can
recreate blindly. Either replicate the stored value at load time (simple, but it goes stale until
the next pull) or recompute it downstream in Postgres (accurate, but you have to reimplement the
formula). Pick per field based on how the value is used. Remember that custom objects end in
__c and custom fields end in __c too, so map those alongside the standard
Account, Contact, Lead, Opportunity, User, and OpportunityLineItem objects.
How do you avoid hitting Salesforce API limits?
Avoid the daily API cap by pulling many rows per SOQL query instead of per-record calls, fetching
only changed records through the SystemModstamp watermark rather than full reloads,
using Bulk API 2.0 for the heavy backfill, and paging with a stable ORDER BY Id. When
REQUEST_LIMIT_EXCEEDED comes back, back off and retry with exponential delay rather
than hammering the endpoint.
Salesforce meters API calls per org per 24 hours, and a live app querying Salesforce directly can exhaust that budget on its own. A Postgres replica is the fix: your apps read the copy, and the pipeline spends a predictable, small slice of the cap. The watermark compounds the saving, because a steady-state run touches only the records that changed since last time, which is a tiny fraction of the object. Reserve full scans for the initial backfill, and run that backfill through Bulk API 2.0 so a single CSV job replaces thousands of paged REST calls.
Treat throttling as expected, not exceptional. Wrap each request in retry logic that recognizes
REQUEST_LIMIT_EXCEEDED and transient errors, waits, and resumes from the same page.
Prefer one query that returns many rows over a loop of per-record lookups, since each call is
counted the same whether it returns one row or two thousand. Keep the integration user scoped to
read only the objects you replicate. If you later add a warehouse target, the same discipline
applies, and you can lean on the
data integration platform to manage both pipelines the same way.
How do you handle deletes and keep the replica in sync without duplicates?
Handle deletes by polling the SOAP getDeleted call (or the Bulk API
isDeleted flag) so the replica drops rows Salesforce already removed, and keep the
copy duplicate-free by upserting on the 18-character Id with INSERT ... ON CONFLICT (sf_id)
DO UPDATE. Because the Id is a stable primary key, a retry after a timeout updates the
existing row instead of inserting a second copy.
A watermark on SystemModstamp catches inserts and updates, but it never sees a hard
delete, because a deleted record stops appearing in queries entirely. Left alone, a replica keeps
rows Salesforce has purged. Poll getUpdated and getDeleted over the same
time window (or read the Recycle Bin with ALL ROWS and the IsDeleted
flag), then either hard-delete those Ids from Postgres or set a is_deleted column for a
soft-delete model that preserves history. Soft deletes are usually safer for analytics, since a
row that vanishes from a report is harder to debug than one flagged inactive.
The load itself has a consistent shape. Land each batch in a staging table, then upsert from staging
into the target with ON CONFLICT (sf_id) DO UPDATE SET ... so inserts and updates
happen in one statement. Idempotence is the whole point: if the pipeline dies mid-run and replays
the last window, every conflicting Id updates rather than duplicates. Add a loaded_at
column so freshness checks are trivial. Once the CRM data lives in Postgres, you can build logic on
top of it to
route every new lead to the right rep the moment
the record lands, instead of waiting on a nightly report.
How much does it cost to replicate Salesforce to Postgres?
The cost has two parts: the Postgres infrastructure you run and the pipeline that feeds it. Postgres itself is inexpensive at CRM volumes, since Salesforce objects are modest compared to event or log data, so a standard managed instance handles a full replica comfortably. The REST and Bulk APIs need no extra Salesforce license beyond the API access your edition already includes.
Storage and compute for the database scale with how much history you keep and how heavily apps query it, both of which you control with indexes and, on the largest objects, a partitioning strategy. The pipeline is the other line item, and this is where metered per-row billing can surprise you: a busy quarter means more changed records, which on a usage-priced connector means a bigger invoice for the exact month you least want one.
On the pipeline side, our pricing is flat by plan (Starter $49, Growth $149, Scale $399, Enterprise custom) rather than metered per row, so a heavy sales month does not produce a spike. See flat pricing for the full breakdown. Keep the database right sized and indexed, keep the pipeline plan flat, and the total cost of a Salesforce to Postgres replica stays predictable as your CRM volume grows.
Replicate Salesforce into Postgres with incremental SOQL and idempotent upserts
Watermark reads on SystemModstamp, Bulk API backfill, ON CONFLICT upserts, and delete handling. Map the pair in the browser and pay a flat price from $49 a month.
No credit card required.