This guide shows how to query a Salesforce org directly from DuckDB using
the duckdb-salesforce extension. The extension attaches your org as a
read-only catalog: Salesforce objects (sObjects) appear as tables you
can SELECT from with plain SQL, while the extension translates your query
into SOQL and the right Salesforce API (REST or Bulk) behind the scenes.
It is practical and example-driven. Every setting and function mentioned here is real; nothing is invented.
This guide follows concepts documented by DuckDB:
ATTACH: attaches another catalog to DuckDB. This extension uses the same model to expose a Salesforce org as a read-only catalog.SELECT: the only statement you run against the Salesforce catalog. All writes and DDL throw.CREATE TABLE:CREATE TABLE ... AS SELECTmaterializes a Salesforce query into a local DuckDB table.- Table functions:
this extension ships diagnostic table functions such as
salesforce_query_cost().
ATTACH exposes the org as a catalog. You can only read from it: every
INSERT, UPDATE, DELETE, and DDL statement against the Salesforce
catalog throws an error. If you need a local, writable copy, materialize a
query into a DuckDB table (see section 5).
Each table in the attached catalog is a Salesforce sObject (Account,
Contact, Opportunity, custom objects ending in __c, and so on).
Schema is resolved lazily the first time you reference an object, so
attaching is cheap and fast.
Authentication is OAuth 2.0 refresh-token only. You supply a
client_id, client_secret, and refresh_token. Credentials live in
memory only and are never logged. TLS certificate verification is always
on.
A local build is unsigned, so DuckDB will not load it unless you opt in:
SET allow_unsigned_extensions=true;
LOAD 'path/to/salesforce.duckdb_extension';For platform-specific build and install steps, see docs/en/guide_windows.md (Windows) and docs/en/guide_linux.md (Linux).
CI-validated platforms today are linux_amd64 + windows_amd64 (baseline) and
osx_arm64 (extra). The extension is not yet published to community-extensions,
so there is no INSTALL ... FROM community yet.
The extension always verifies the TLS server certificate when it talks to
Salesforce. There is no insecure flag, ever. On macOS the OpenSSL that the
extension is built against (via vcpkg) ships no default CA bundle and does
not read the macOS Keychain, so a live ATTACH can fail certificate
verification with no trust anchors to check against.
The fix is to point OpenSSL at a CA bundle through the SSL_CERT_FILE
environment variable (OpenSSL also honors SSL_CERT_DIR for a directory of
certificates). This only selects the trust anchors — verification stays ON,
it is not a bypass:
export SSL_CERT_FILE=$(brew --prefix)/etc/openssl@3/cert.pem # Homebrew OpenSSL bundle
export SSL_CERT_FILE=$(python3 -m certifi) # Python certifi bundleA live ATTACH that fails verification on macOS now prints this exact
suggestion in the error message. Linux and Windows need no such step: Linux
uses the system bundle and Windows uses the OS trust store. A zero-config macOS
Keychain trust store is a planned follow-up, not yet shipped.
Attach the org with the salesforce catalog type and your OAuth
credentials:
ATTACH 'salesforce://<org>' AS sf (
TYPE salesforce,
client_id 'your_consumer_key',
client_secret 'your_consumer_secret',
refresh_token 'your_refresh_token'
);Two optional parameters cover sandboxes and pinned API versions:
ATTACH 'salesforce://<org>' AS sf (
TYPE salesforce,
client_id 'your_consumer_key',
client_secret 'your_consumer_secret',
refresh_token 'your_refresh_token',
login_url 'https://login.salesforce.com',
api_version 'v60.0'
);Use login_url 'https://test.salesforce.com' for a sandbox. To obtain the
client_id, client_secret, and refresh_token, set up a Connected App
in your org and complete the OAuth flow as described in
docs/CONNECTED_APP.md.
For policy standardization the connector also accepts read_only as an
ATTACH option (e.g. when your tooling appends READ_ONLY TRUE to every
source). It is accepted and ignored: the catalog is read-only by design -
every mutating operation throws - so the flag changes nothing.
The auth_source ATTACH option chooses where the OAuth credentials come
from. The OAuth refresh-token flow is unchanged — only the source differs.
There are four modes:
-
options(default): credentials are read from the inlineATTACHoptionsclient_id,client_secret, andrefresh_token(all required), plus optionallogin_urlandapi_version. This is exactly the behavior shown above; you can omitauth_sourceentirely. -
env: credentials are read from environment variables. The required ones areSF_CLIENT_ID,SF_CLIENT_SECRET, andSF_REFRESH_TOKEN;SF_LOGIN_URLis optional. You do not pass the credential options in SQL.ATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'env');
-
sfdx_url: credentials are read from a single environment variable,SF_SFDX_AUTH_URL, in the Salesforce CLI auth-URL formatforce://<clientId>:<clientSecret>:<refreshToken>@<instance-host>(the<clientSecret>segment may be empty). You do not pass the credential options in SQL.ATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'sfdx_url');
-
jwt: the OAuth 2.0 JWT bearer flow, ideal for headless / CI / pipeline use. There is no refresh token: the extension RS256-signs a short-lived JWT assertion (iss=client_id,sub=username,aud=login_url,exp= now + 300s) and exchanges it at<login_url>/services/oauth2/token. The access token is held in memory; on a401the extension simply re-signs a fresh assertion. You provideclient_id(the issuer) andusername(the Salesforce user to impersonate) in SQL, and a private key through theSF_JWT_KEY_FILEenvironment variable (recommended) or the inlineprivate_key_fileoption (local dev only). The key must be an unencrypted PKCS#1 or PKCS#8 PEM RSA key.login_url(defaulthttps://login.salesforce.com; usehttps://test.salesforce.comfor a sandbox) andapi_versionwork as in the other modes.ATTACH 'salesforce://production' AS sf ( TYPE salesforce, auth_source 'jwt', client_id 'YOUR_CONSUMER_KEY', username 'svc@example.com' );
Prerequisite: the Connected App must be pre-authorized for the user — either admin-approved, or pre-authorized by the user through the digital certificate ("Use digital signatures") JWT setup.
The api_version option works in all four modes. With env, sfdx_url, and
jwt, the refresh-token credential options are not needed (the source wins).
Export the variables in your shell, then start DuckDB and attach:
export SF_CLIENT_ID=your_consumer_key
export SF_CLIENT_SECRET=your_consumer_secret
export SF_REFRESH_TOKEN=your_refresh_token
# optional: export SF_LOGIN_URL=https://test.salesforce.com
duckdbATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'env');Or, using a Salesforce CLI auth URL captured from an authorized org:
export SF_SFDX_AUTH_URL=$(sf org display --verbose --json | jq -r .result.sfdxAuthUrl)
duckdbATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'sfdx_url');The application or container injects the SF_* environment variables (for
example, pulled from a secret manager at startup). The backend opens DuckDB
and attaches with auth_source 'env', so no credentials ever appear in the
SQL text:
ATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'env');import os
import duckdb
os.environ["SF_CLIENT_ID"] = "your_consumer_key"
os.environ["SF_CLIENT_SECRET"] = "your_consumer_secret"
os.environ["SF_REFRESH_TOKEN"] = "your_refresh_token"
con = duckdb.connect()
con.execute(
"ATTACH 'salesforce://prod' AS sf (TYPE salesforce, auth_source 'env')"
)The JWT mode needs no refresh token — only client_id, username, and the
private key. Keep the key path in SF_JWT_KEY_FILE so it never appears in the
SQL. Prerequisite: the Connected App must be pre-authorized for the user
(admin-approved, or pre-authorized via the digital certificate / "Use digital
signatures" JWT setup).
Terminal — export the key path, then start DuckDB and attach:
export SF_JWT_KEY_FILE=/secure/server.key
# optional: export SF_LOGIN_URL=https://test.salesforce.com
duckdbATTACH 'salesforce://production' AS sf (
TYPE salesforce, auth_source 'jwt',
client_id 'YOUR_CONSUMER_KEY', username 'svc@example.com'
);Web UI / backend service — the application or container injects
SF_JWT_KEY_FILE (for example, a mounted secret). The backend attaches with
auth_source 'jwt', so no key path or secret ever appears in the SQL:
ATTACH 'salesforce://production' AS sf (
TYPE salesforce, auth_source 'jwt',
client_id 'YOUR_CONSUMER_KEY', username 'svc@example.com'
);Python / pipeline:
import os
import duckdb
os.environ["SF_JWT_KEY_FILE"] = "/secure/server.key"
con = duckdb.connect()
con.execute(
"ATTACH 'salesforce://prod' AS sf (TYPE salesforce, auth_source 'jwt', "
"client_id '...', username 'svc@example.com')"
)Local dev (inline key) — for local development only, you can pass the key
path inline instead of through the environment; prefer SF_JWT_KEY_FILE in
pipelines:
ATTACH 'salesforce://production' AS sf (
TYPE salesforce, auth_source 'jwt',
client_id 'YOUR_CONSUMER_KEY', username 'svc@example.com',
private_key_file '/path/to/server.key'
);Environment values and the SFDX auth URL are never logged, and tokens or
secrets never appear in error messages. A missing environment variable names
only the variable; a malformed SF_SFDX_AUTH_URL is reported as malformed
without echoing it; invalid_grant is reported as "refresh token is invalid,
expired, or revoked"; invalid_client as "client_id / client_secret is
incorrect".
For jwt, the private key, the assembled JWT, and the signed assertion are
never logged and never appear in error messages. A missing key path names the
option / variable expected (private_key_file or SF_JWT_KEY_FILE); a missing
key file names only the path; a non-PEM, unparseable, or encrypted key is
reported without echoing its contents; invalid_grant from the JWT exchange is
reported as "invalid, expired, or the user is not authorized for this Connected
App".
These are out of scope for this release: a browser / web OAuth flow, secret storage, encrypted private keys, an OS credential manager, and token / key persistence.
Once attached, query sObjects like any other table:
SELECT Id, Name
FROM sf.Account
WHERE Name = 'Acme'
LIMIT 10;Id literals in filters are validated locally at plan time: an =, !=, or
IN literal compared to an Id-type field must be a real Salesforce Id (15 or
18 alphanumeric characters). A malformed value fails immediately with a clear
error instead of Salesforce's generic INVALID_QUERY_FILTER_OPERATOR.
Schema is resolved lazily on first reference. To list the objects you can query, run a single global describe via:
SHOW TABLES;
-- or
SELECT * FROM duckdb_tables();Custom Metadata Types (objects whose API name ends in __mdt) and queryable
Custom Settings (objects whose API name ends in __c, both List and Hierarchy
types) need no special syntax: they appear as ordinary read-only sObject
tables in the attached catalog. When they are queryable they show up in the
global object listing (SHOW TABLES / duckdb_tables()), and DESCRIBE plus
SELECT work exactly like any other object — projection and predicate pushdown
apply just the same:
-- a Custom Metadata Type
SELECT DeveloperName, Label
FROM sf.Shipping_Rate__mdt
WHERE Label = 'Domestic';
-- a Custom Setting
SELECT Name, SetupOwnerId
FROM sf.Feature_Flag__c
LIMIT 10;Keep these limitations in mind:
- This is data access, not the Salesforce Metadata API. The connector reads these objects as rows; it does not deploy, create, or modify metadata.
- Visibility follows the authenticated user's and org's permissions. Protected Custom Metadata and protected Custom Settings may not appear or may not be readable, per Salesforce.
- All writes throw, the same as for any sObject (the catalog is read-only).
- If you add a new
__mdttype or a new field mid-session, callsalesforce_refresh_metadata('<catalog>')(orsalesforce_refresh_metadata('<catalog>', '<Object>')) so the next reference re-describes it.
To keep a local, queryable snapshot (and to escape read-only limits), copy a Salesforce query into a DuckDB table:
CREATE TABLE local_accounts AS
SELECT Id, Name, Industry, AnnualRevenue
FROM sf.Account
WHERE Industry = 'Manufacturing';From then on you can query, join, and aggregate local_accounts with no
further API calls.
The extension can fetch rows over different Salesforce APIs. Choose with
sf_force_transport:
-- REST (default): lazy /query + queryMore paging
SET sf_force_transport='rest';
-- Bulk API 2.0: lazy-streamed result pages, optional PK chunking
SET sf_force_transport='bulk';
-- auto: probe row count, then pick rest or bulk
SET sf_force_transport='auto';- rest (default) uses the REST
/query+queryMorepaging path, fetched lazily. - bulk uses Bulk API 2.0. Result pages are streamed lazily, with optional PK chunking (section 7).
- auto runs one
SELECT COUNT()to probe the row count, then picks rest or bulk based onsf_auto_bulk_threshold(default 50000). The probe is controlled bysf_auto_probe(default true).
Important about LIMIT with Bulk: a Bulk LIMIT is not server-side,
so the job runs fully. However, because result pages are streamed lazily, a
small LIMIT stops downloading later pages. Note that auto cannot see
LIMIT when probing, so for a small-LIMIT query against a huge object,
force rest explicitly:
SET sf_force_transport='rest';
SELECT Id, Name FROM sf.Account ORDER BY CreatedDate DESC LIMIT 20;COUNT(*) and zero-column scans run a SELECT COUNT() (no record paging)
when there is no residual filter and the scan is not forced to bulk:
SELECT COUNT(*) FROM sf.Contact;Salesforce Bulk API 2.0 query results are delivered as CSV, and that CSV
does not return blob/base64 fields. Forcing such a field through Bulk
yields the Salesforce error "Blob field not supported in Bulk V2 Query with
CSV content type". In DuckDB, base64 Salesforce fields map to the BLOB
type.
The extension guards against this from metadata alone: if a projected field
is of the base64 type — at any depth, including a base64 field inside a
parent-relationship STRUCT — the three transports behave as follows:
- auto: the scan stays on REST and never picks Bulk. The recorded reason
is
auto: bulk-incompatible (projected base64 field 'NAME') -> rest. - bulk (forced): the scan fails with a clear error before any Bulk
job is created —
projected base64 field 'NAME' is not supported by Bulk API 2.0 CSV; use 'rest' or 'auto'. - rest (default): unchanged — REST returns blob fields as base64 in JSON.
If the base64 field is not projected, Bulk is allowed as usual. The
decision and its reason are visible in salesforce_last_transport() and
salesforce_query_cost() (both report the transport and the reason).
Practical guidance: to read a blob/base64 column, use REST (the default), or simply don't project that column in a Bulk scan.
This guard is metadata-driven and covers base64 fields only. Other Bulk-unsupported objects are not pre-listed; if forced to Bulk, they surface as a clear Salesforce Bulk-job error instead.
The §11 guard above is about the Bulk transport. REST behaves differently
for blob/base64 BODY fields — for example Attachment.Body or
ContentVersion.VersionData. A REST query returns those fields not as inline
base64, but as a URL reference that points to the binary payload. The
scanner does not follow that URL: there is no automatic attachment/blob
download.
Selecting such a field over REST therefore raises a clear, documented error instead of silently returning the reference:
Salesforce returned a URL reference for blob/base64 field 'NAME'; inline
BLOB decoding is not supported by REST query. Select non-blob fields or
fetch the blob URL outside the scanner.
This limitation applies specifically to blob body fields returned as URL
references. Small/inline base64 fields that REST returns inline still decode
to BLOB normally.
Putting both transports together for blob/base64 body fields:
- Bulk (§11): not supported at all — the guard errors, and
autostays on REST. - REST (§11a): returns a URL reference, which is not decodable inline — the scanner raises the clear error above.
Net: blob body fields are not directly readable as bytes through this
connector on either transport. Practical guidance: select the non-blob
fields you need (so the scan succeeds), and fetch the blob out-of-band via
the Salesforce API using the record Id.
The connector expects Salesforce datetime, date, and time values as
ISO 8601 strings — for example the datetime 2024-01-15T10:20:30.000+0000,
the date 2024-01-15, and the time 10:20:30.000Z. These decode to the
DuckDB TIMESTAMP, DATE, and TIME types respectively. The typed decode is
the same on both transports: REST returns the value in JSON and Bulk
returns it in CSV, and either way it goes through one decode path.
Timestamps are normalized to the UTC wall-clock value: the timezone offset
is stripped, not shifted. So 2024-01-15T10:20:30.000+0000 and a value with a
non-zero offset both surface their UTC wall-clock reading as the TIMESTAMP.
A numeric / epoch value in a datetime/date/time field — for example
1705314030000 — is not interpreted. The connector does not guess
seconds-versus-milliseconds; instead it raises a clear, field-named error such
as:
field 'Whn' (Salesforce type 'datetime') could not be decoded as TIMESTAMP
The offending value is never echoed in the message. This is intentional: the connector fails clearly rather than produce a bogus timestamp. There is no confirmed case of Bulk API 2.0 query returning epoch datetimes; if a real org ever does, that is a bug to report with a (structural) example, not something the connector guesses at.
Practical guidance: if you must handle a numeric/epoch time value, transform it
outside the scanner — for example fetch the raw number some other way and
apply DuckDB's to_timestamp to it.
Transient HTTP failures (429, 5xx, connection errors) are retried
automatically: sf_retry_max attempts (default 3) with sf_retry_backoff_ms
linear backoff (default 200 ms). Each attempt waits at most
sf_timeout_read_s seconds (default 60, cap 3600) for the server's response.
Raise the timeout when a legitimate server-side COUNT() or a scan on a very
large object exceeds it and fails instead of completing - for example
SET sf_timeout_read_s = 300. See the function manual for the full semantics.
For very large objects, Bulk API 2.0 plus PK chunking parallelizes the
extraction. sf_bulk_chunks (default 1 = off, cap 8, Bulk-only) splits a
Bulk scan into N disjoint Id ranges. The extension probes MIN(Id) /
MAX(Id), performs a lexical split, and runs one Bulk job per chunk in
parallel (up to one thread per chunk):
SET sf_force_transport='bulk';
SET sf_bulk_chunks=8;
CREATE TABLE all_opps AS
SELECT Id, Name, Amount, StageName, CloseDate
FROM sf.Opportunity;Caveats: there is no global row order across chunks, and chunks may be
uneven or even empty depending on how Id values distribute.
A first full load of a large object is the failure mode that motivated the
range-pushdown work: a single ~millions-of-rows Bulk read can exhaust the
poll budget, and a CreatedDate >= X AND CreatedDate < Y window only helps
if that range actually pushes to SOQL. Follow this sequence instead of
pointing Bulk at the whole object.
1. Prove the predicate pushes before you extract. A selective-looking
WHERE is worthless if DuckDB is filtering after a full remote scan. Run
the window through salesforce_query_cost() and confirm the range is server-
side:
SELECT Id FROM sf.Produto_Oferta__c
WHERE CreatedDate >= TIMESTAMP '2024-01-01 00:00:00'
AND CreatedDate < TIMESTAMP '2024-02-01 00:00:00'
LIMIT 0;
SELECT where_pushed, pushed_filters, residual_filters, guidance
FROM salesforce_query_cost();
-- want: where_pushed contains the CreatedDate range, residual_filters = 0,
-- guidance = "Salesforce is filtering server-side". If guidance instead says
-- "DuckDB is filtering after a full remote scan", the range did NOT push —
-- fix the predicate before extracting (see §14 for what stays residual).COUNT(*) over the same window pushes to a single SELECT COUNT() and
returns the per-window row count for free (zero record egress), so you can
size each window:
SELECT COUNT(*) FROM sf.Produto_Oferta__c
WHERE CreatedDate >= TIMESTAMP '2024-01-01 00:00:00'
AND CreatedDate < TIMESTAMP '2024-02-01 00:00:00';
salesforce_aggregate()(§13) can also validate server-side selectivity, but it proves only that Salesforce can filter the range — a normal extraction still has to prove the scan pushes the same predicate throughsalesforce_query_cost(). They are different code paths.
2. Split the seed into pushed CreatedDate (or SystemModstamp) windows
and write each through DuckDB to Parquet or a lake table. Each window is its
own bounded Bulk read:
SET sf_force_transport='bulk';
SET sf_bulk_require_predicate=true; -- refuse an accidental full-object read
SET sf_bulk_poll_budget=2000; -- a large window may need > 600 polls
COPY (
SELECT Id, Name, Amount, CreatedDate, SystemModstamp
FROM sf.Produto_Oferta__c
WHERE CreatedDate >= TIMESTAMP '2024-01-01 00:00:00'
AND CreatedDate < TIMESTAMP '2024-02-01 00:00:00'
) TO 'lake/produto_oferta/2024-01.parquet' (FORMAT parquet);With sf_bulk_require_predicate=true, a Bulk read whose predicate did not
push fails fast before a job is created — so a mistake surfaces as an error,
not a multi-million-row over-fetch. After each window, check
salesforce_query_cost().bulk_polls to see how close the job ran to the
budget and raise sf_bulk_poll_budget if needed.
3. Maintain incrementally on SystemModstamp. Once seeded, refresh only
the changed rows with a half-open window from the last watermark, re-reading
a short lookback to absorb clock skew:
COPY (
SELECT Id, Name, Amount, CreatedDate, SystemModstamp
FROM sf.Produto_Oferta__c
WHERE SystemModstamp >= TIMESTAMP '2024-06-01 00:00:00' -- last watermark - lookback
) TO 'lake/produto_oferta/incr-2024-06.parquet' (FORMAT parquet);Drive the windows and watermarks from your orchestrator (dbt, a scheduler, etc.); the extension itself does not manage checkpoints or schedules.
To avoid burning your daily API allotment, a quota governor gates Bulk
job starts (REST is not gated). Before starting a Bulk job, the extension
reads GET /limits and refuses the job when remaining requests are at or
below a threshold:
threshold = max(sf_quota_min_remaining, sf_quota_reserve_pct% of DailyApiRequests.Max)
Defaults: sf_quota_min_remaining is 1000 and sf_quota_reserve_pct is 10
(percent). Relevant settings:
SET sf_quota_enabled=true; -- master switch (default true)
SET sf_quota_enforce=true; -- false = warn only (default true)
SET sf_quota_fail_open=true; -- /limits unavailable -> allow (default true)
SET sf_quota_cache_seconds=60; -- cache /limits result (default 60)
SET sf_quota_min_remaining=1000; -- absolute floor (default 1000)
SET sf_quota_reserve_pct=10; -- reserve % of daily max (default 10)HTTP 429 responses are retried automatically;
REQUEST_LIMIT_EXCEEDED is terminal.
By default, relationship columns are off. Set sf_relationships='parent'
to expose single-target parent lookups as STRUCT columns:
SET sf_relationships='parent';
SELECT Account.Name
FROM sf.Contact
LIMIT 10;Scope and caveats:
- Depth is 1 (direct parent only) by default; raise
sf_relationship_depthto2to also expand the grandparent. - Polymorphic lookups are skipped.
- Predicates on subfields are evaluated as residual filters (in DuckDB).
- The default is
'off'.
Set sf_relationship_depth=2 to expand a single-target parent
relationship's own single-target parent (the grandparent) as a nested
STRUCT child. For example, Contact → Account → Owner (User):
SET sf_relationships='parent';
SET sf_relationship_depth=2;
SELECT Account.Owner.Name
FROM sf.Contact
LIMIT 10;Scope and caveats:
- Depth is strictly capped at
2(not Salesforce's full 5-level chains). - Single-target only at each hop: polymorphic relationships are skipped at every level, and self-references or cycles are skipped.
- Predicates on subfields stay residual — there is no pushdown on
Account.Owner.NameinWHERE, the same as at depth 1. - Over-fetch grows with depth: selecting the STRUCT fetches all scalar fields at every expanded level.
sf_schema_source controls how object schema is discovered:
-- describe (default): REST describe, authoritative
SET sf_schema_source='describe';
-- tooling: fast batched Tooling FieldDefinition
SET sf_schema_source='tooling';- describe (default) uses the REST describe call and is authoritative.
- tooling uses the fast, batched Tooling
FieldDefinitionAPI, with a per-object REST fallback. It produces coarser types and reduces pushdown, so prefer it only when describe is too slow for your workflow.
By default a scan returns only live records. Set sf_query_mode='queryAll'
to read via Salesforce's queryAll capability, which also returns archived
and soft-deleted records (IsDeleted = true) alongside live rows:
SET sf_query_mode='queryAll';
SELECT Id, Name, IsDeleted
FROM sf.Account
LIMIT 10;Scope and caveats:
- Allowed values are
'query'(default) and'queryAll'. - The mode applies across the whole scan: REST uses the
/queryAllendpoint, Bulk submits a job withoperation: "queryAll", and theCOUNT()/MIN-MAX(Id)probes use it too — so COUNT pushdown,autotransport selection, and PK-chunk ranges all reflect deleted and archived rows. - Use the
IsDeletedcolumn to tell deleted/archived rows apart from live ones. - This is not history, CDC, or replication, and not a local snapshot — it only exposes the Salesforce read capability for that scan.
- The
salesforce_query()utility is query-only and ignores this setting.
A delete updates SystemModstamp to the deletion time, and the deleted row stays visible via queryAll (IsDeleted = true) until purged (~15 days) — a watermark load running queryAll inside that window captures deletes within the watermark itself. What it misses: purges (bin emptied before the next load) and objects with no IsDeleted field (e.g. User) — covered by salesforce_deleted_ids() (getDeleted source). A watermark-based incremental
load (WHERE SystemModstamp > :last_watermark) never notices them — deleted
rows stay alive in your target table forever. Pair the watermark with a
periodic delete sweep:
-- option 1: delete sweep via queryAll (server-side predicate; rows stay
-- sweepable for ~15 days in the recycle bin)
SET sf_query_mode='queryAll';
SELECT Id FROM sf.Lead WHERE IsDeleted = true;
-- -> mark `is_deleted = true` (or delete) these ids in your target, then
-- reset sf_query_mode='query'
-- option 2: exact window via the Replication API (getDeleted), with
-- deletion timestamps (windows up to 15 minutes per call)
SELECT id, deleted_date, source
FROM salesforce_deleted_ids('sf', 'Lead',
since := '2026-09-30T00:00:00', until := '2026-09-30T00:15:00');
-- longer horizon: queryAll fallback, deleted_date is NULL (~15-day retention)
SELECT id FROM salesforce_deleted_ids('sf', 'Lead', since := '2026-09-20T00:00:00');Run the sweep on a schedule (e.g. daily): upsert the changed rows from the watermark load, then mark/remove the ids the sweep returns.
salesforce_scan(catalog, object) scans an sObject using an attached
catalog's credentials — useful for per-query configuration without touching
session settings (e.g. from orchestrators that share connections):
-- raw SOQL predicate appended server-side (validated: no ';', no nested
-- SELECT, <= 4000 chars)
SELECT Name FROM salesforce_scan('sf', 'Lead', filter := 'Status = ''Open''')
LIMIT 10;
-- per-call mode/transport/chunks overrides
SELECT count(*) FROM salesforce_scan('sf', 'Lead', query_mode := 'queryAll');
SELECT Id FROM salesforce_scan('sf', 'Account', transport := 'bulk', chunks := 4);Projection and plan-predicate pushdown work exactly as on catalog tables,
and the generated SOQL is visible via salesforce_last_soql().
If a load dies mid-stream of a Bulk job that already completed server-side,
salesforce_bulk_resume(catalog, job_id) re-streams the result rows without
re-running the query. Columns come from the job's CSV header (VARCHAR):
SELECT Id, Name
FROM salesforce_bulk_resume('sf', '7504x00000ABCDef');The extension ships user-facing table functions that explain what the last
scan did. The richest is salesforce_query_cost():
SELECT * FROM salesforce_query_cost();It returns, among other columns: object, soql, transport,
est_rows, transport_reason, projected_fields, total_fields,
pushed_filters, residual_filters, where_pushed, pages_fetched,
rows_emitted, bulk, count_pushdown, bulk_chunks, bulk_polls,
quota_remaining, quota_allowed, and guidance. guidance states
whether Salesforce filtered server-side or DuckDB filtered after a full
remote scan; bulk_polls is the Bulk job status-poll count.
Focused helpers return a single aspect of the last scan:
SELECT * FROM salesforce_last_transport(); -- transport actually used
SELECT * FROM salesforce_last_quota(); -- quota snapshot
SELECT * FROM salesforce_last_soql(); -- SOQL that was sent
SELECT * FROM salesforce_last_scan_pages(); -- pages fetchedA typical workflow is to run your query, then inspect
salesforce_query_cost() to confirm the transport, the pushed vs. residual
filters, and the guidance column for tuning hints.
Two read-only metadata diagnostics, sourced through the shared Metadata Engine (one de-duped Describe Global + per-object Describe per catalog), let you explore the org schema without leaving DuckDB:
-- What can I query here?
SELECT object_name FROM salesforce_metadata_objects('sf')
WHERE queryable ORDER BY object_name;
-- What are the fields of Account, and which can be filtered server-side?
SELECT field_name, type, filterable, sortable, relationship_name, reference_to, picklist_values
FROM salesforce_metadata_fields('sf', 'Account')
ORDER BY field_name;filterable is the key column for tuning: a WHERE on a non-filterable field
cannot be pushed to Salesforce and is applied residually in DuckDB. The cache is
invalidated by salesforce_refresh_metadata() (see below).
salesforce_query_cost() summarizes a scan in one row; salesforce_query_explain()
breaks the same last scan down field by field, so you can see exactly which
predicate was pushed to Salesforce and which was left for DuckDB:
SELECT count(*) FROM sf.Account WHERE Industry = 'Technology';
SELECT object_name, field_name, role, pushed, residual, reason
FROM salesforce_query_explain()
ORDER BY role, field_name;Read the result by role:
filterrows —pushed=true, reason='pushed_to_soql'means Salesforce filtered server-side (good).residual=truewithreason='not_filterable'means the field is not filterable, so DuckDB filtered after a full remote scan — an over-fetch worth avoiding.complex_expression(anOR/NOT/ cross-field predicate,field_nameNULL) andunsupported_operatorare also residual.projectionrows — one per selected field (reason='projected'); a reference field also showsrelationship_name+reference_to.relationshiprows — one per parent relationship the scan actually traversed (reason='relationship_traversed').countrow —count_pushdown(served bySELECT COUNT(), no records fetched) orcount_not_pushed.transportrow —transport_restortransport_bulk; theguidancecolumn carries the reason,queryAll, andest_rows.
Comparing the two diagnostics:
-- one-row summary
SELECT object, transport, pushed_filters, residual_filters, where_pushed, count_pushdown
FROM salesforce_query_cost();
-- per-field breakdown of the SAME scan
SELECT field_name, role, pushed, residual, reason FROM salesforce_query_explain();Notes:
- Last-scan, no parameters.
salesforce_query_explain()reflects the most recent scan in the session and takes no arguments. Before any scan it returns zero rows (it never invents a default row). metadata_unavailable. If the catalog was detached (or metadata is otherwise unavailable), the engine-annotated rows degrade toreason='metadata_unavailable'withresolved=false; thecount/transportrows still appear. The function never errors.LIMITis not shown. In this DuckDB build the table function never receives the queryLIMIT(DuckDB applies it above the scan), so it is not diagnosable and is omitted rather than guessed.- Diagnostic-only. Neither function changes how scans run; they explain, they do not speed up. Scan behavior is unchanged.
salesforce_relationships() is a separate diagnostic that reports what
parent-relationship expansion did the last time an object's schema was
resolved (schema resolution happens on the first SELECT or DESCRIBE of
an object — re-querying a cached schema does not re-resolve). It complements
salesforce_query_cost(), which reports per-scan cost; this one reports
per-schema-resolution relationship expansion.
Resolve an object's schema first, then call it:
SET sf_relationships='parent';
SET sf_relationship_depth=2;
SELECT Id FROM sf.Contact LIMIT 1; -- triggers schema resolution
SELECT row_type, relationship_name, parent_object, depth_level, status, reason, field_count
FROM salesforce_relationships();The result always starts with exactly one config row (mode, effective
depth, and the expanded_count / skipped_count totals), followed by one
relationship row per reference field considered. Read it to answer four
questions:
- Over-fetch. An expanded parent is fetched as a full STRUCT of
every queryable parent scalar field, and nested projection is not pushed
into SOQL — so selecting
Account.Namestill fetches the whole parent. Thefield_countcolumn (with thenoteon expanded rows) shows how wide that over-fetch is. - Skipped relationships. A
relationshiprow withstatus='skipped'carries areason—polymorphic(for example aWhatlookup, withparent_objectNULL),self_reference,cycle,name_collision,parent_not_describable,no_fields, orno_relationship_name. - Depth.
depth_level=1is the direct parent anddepth_level=2is the grandparent (for example Contact → Account → Owner). - The always-present config row. Even with
sf_relationships='off'you still get the single config row (relationships_mode='off') and norelationshiprows — so "off" never looks like an empty or broken result:
SET sf_relationships='off';
SELECT Id FROM sf.Account LIMIT 1;
SELECT * FROM salesforce_relationships(); -- one config row onlyThe connector caches object metadata in memory per ATTACH: each object's
schema (resolved lazily on first reference), the global object listing, and the
parent describes used for relationship expansion. There is no data cache and no
on-disk cache. That cache is what makes a long session fast, but it also means a
schema change made in Salesforce — a new custom field, a new object — is not
seen until the cache is cleared.
salesforce_refresh_metadata(catalog [, object]) clears that cache so the
next reference re-fetches from Salesforce. It makes no network call itself
— it only drops cached entries. Use it to pick up org schema drift within a
long-lived session without a DETACH / ATTACH.
The optional object argument is the global-vs-object distinction:
- Global — omit
objectto drop the object listing and every resolved object schema (and parent describes). The next listing scan re-fetches, and the next reference to any object re-describes. - Object — pass an sObject name to clear only that object's resolved schema (and its parent-describe entry). Other objects and the object listing are left intact.
-- after adding a field to Account in Salesforce:
SELECT * FROM salesforce_refresh_metadata('sf', 'Account'); -- re-describe Account on next query
-- or refresh everything (schemas + object listing):
SELECT * FROM salesforce_refresh_metadata('sf');It returns one row: catalog (the alias), scope ('global' or 'object'),
and object (the object name, or NULL for a global refresh). The catalog
must be an attached Salesforce catalog — otherwise you get a clear,
secret-free error (no attached catalog named '<x>' or catalog '<x>' is not a Salesforce catalog).
Two read-only metadata table functions expose a field's picklist values and an
object's record types as rows. Both read from the object's standard REST
describe — no Metadata API, no SOAP, no Tooling — so they never deploy or change
anything. Both reuse an org you have already attached (the first argument is the
ATTACH alias), and the describe is cached per ATTACH and shared by both;
clear it with salesforce_refresh_metadata() to pick up a new value or record
type mid-session.
salesforce_picklist_values(catalog, object, field) returns one row per
picklist value of a field — value, label, active, and is_default. It is
the field's full catalog: active and inactive values, not filtered by
record type and without resolving dependent picklists. Add WHERE active for
the active subset:
-- all Industry picklist values (active + inactive)
SELECT value, label FROM salesforce_picklist_values('sf', 'Account', 'Industry');
-- active only
SELECT value FROM salesforce_picklist_values('sf', 'Account', 'Industry') WHERE active;A field that exists but is not a picklist returns 0 rows (not an error). An unknown catalog alias, an alias that is not a Salesforce catalog, or a field not found on the object gives a clear, secret-free error.
salesforce_record_types(catalog, object) returns one row per record type —
developer_name, label, record_type_id, active, and is_default:
-- record types
SELECT developer_name, label, is_default FROM salesforce_record_types('sf', 'Account');Transparent MIN/MAX/SUM/AVG pushdown is not available yet (aggregate
pushdown is COUNT-only on a normal scan). When you want an aggregate computed
on Salesforce — so the rows are never dragged down into DuckDB — call the
salesforce_aggregate() table function explicitly. It reuses an org you have
already attached, so there is no re-auth and no credentials in the call.
The arguments are
salesforce_aggregate(catalog, object, aggregates [, filter [, group_by]]),
all VARCHAR string literals: the ATTACH alias (here 'sf'), the sObject, a
comma-separated list of SOQL aggregate terms, an optional WHERE body
(without the WHERE keyword), and an optional group_by. The allowed
aggregate functions are MIN, MAX, SUM, AVG, COUNT, and
COUNT_DISTINCT.
Without group_by it returns exactly one row, one VARCHAR column per term.
Cast the results in DuckDB:
SELECT CAST(n AS BIGINT) AS n
FROM salesforce_aggregate('sf', 'Account', 'COUNT(Id) n');Give each term an alias (SOQL style, space-separated) to name its column;
without an alias the columns are expr0, expr1, ... in order. Add a filter
as the fourth argument — note the doubled single quotes for the inner string
literal:
SELECT
CAST(minRev AS DECIMAL(18,2)) AS min_rev,
CAST(maxRev AS DECIMAL(18,2)) AS max_rev,
CAST(n AS BIGINT) AS n
FROM salesforce_aggregate(
'sf', 'Account',
'MIN(AnnualRevenue) minRev, MAX(AnnualRevenue) maxRev, COUNT(Id) n',
'Industry = ''Technology''');Add a fifth argument, group_by, to aggregate per group instead of over the
whole object. It is a comma-separated list of simple field identifiers
(for example 'Industry' or 'Industry, Type') and the function builds
SELECT <group_by>, <aggregates> FROM <object> [WHERE <filter>] GROUP BY <group_by>.
It now returns one row per group: the group columns come first (named by
field), then the aggregate columns. Because group_by is positional after
filter, group without a filter by passing an empty string for the filter:
SELECT Industry, CAST(n AS BIGINT) AS account_count
FROM salesforce_aggregate('sf', 'Account', 'COUNT(Id) n', '', 'Industry')
ORDER BY account_count DESC;To group only the matching rows, pass a non-empty filter together with
group_by — for example
salesforce_aggregate('sf', 'Account', 'COUNT(Id) n', 'AnnualRevenue > 0', 'Industry').
It honors sf_query_mode, and the SOQL it sends shows up in
salesforce_last_soql() and salesforce_query_cost().
Scope and caveats:
- Every term must be an aggregate function — bare fields are rejected, which is what keeps the single-row result.
GROUP BYis supported through the optionalgroup_byargument, but only for simple field identifiers (no dotted / relationship fields, no expressions).ROLLUP,CUBE, andHAVINGare out of scope and rejected with a clear error. Withoutgroup_bythe function still returns exactly one row.;and nestedSELECTare rejected; theobjectmust be a valid identifier; arguments are length-capped.- This is not transparent pushdown — you opt in by calling the function; there is no optimizer or plan rewrite. Any Salesforce error from a relationship / polymorphic aggregate surfaces verbatim.
Bridge Salesforce reports into DuckDB for discovery and validation — not large extraction. A report is a business-authored definition, not a SOQL query, and the synchronous Reports API caps at 2,000 rows. Three read-only functions:
-- 1. discover report definitions (not data)
SELECT Id, Name, Format FROM salesforce_reports('sf');
-- 2. run a small TABULAR sample (max 2000 rows); reserved __sf_report_* columns
-- carry diagnostics (truncated / all_data / max_rows / guidance)
SELECT * FROM salesforce_report('sf', '00O...');
-- 3. structured ingredients + a best-effort candidate SOQL to validate
SELECT soql, translatable, caveats FROM salesforce_report_soql('sf', '00O...');Intended flow: validate the report in Salesforce → run a ground-truth sample
with salesforce_report() → inspect field names + candidate SOQL via
salesforce_report_soql() → validate the candidate against the sample → then
materialize at scale through normal sf.<Object> scans (Bulk, PK chunking,
pushdown — §7).
Key caveats:
salesforce_report()is a sample/oracle, not an extraction path. Check__sf_report_truncated. A report returning 0 rows yields 0 rows and no diagnostic row — truncation is only visible when the sample is non-empty.salesforce_report_soql().soqlis a candidate, not an equivalence contract: it is produced only for single-object tabular reports with safe identifiers and supported operators; otherwisetranslatable = false,soql = NULL, andcaveatsexplain why. Always validate it against the sample before scaling.- Summary/matrix reports are unsupported in this cut (tabular only).
Know these boundaries before you build on the extension:
- Read-only. All writes and DDL against the Salesforce catalog throw.
- Bulk
LIMITis not server-side; the Bulk job runs fully (lazy streaming still stops later page downloads on a smallLIMIT). - auto cannot see
LIMITwhen probing. For a small-LIMITquery on a huge object, forcerest. - Relationships are parent-only, depth 1–2 (
sf_relationship_depth, capped at 2); polymorphic lookups are skipped and subfield predicates are residual. - Tooling schema source produces coarser types and reduces pushdown.
- Aggregate pushdown is COUNT-only.
- PK chunks have no global order, and may be uneven or empty.
- CI-validated platforms are
linux_amd64+windows_amd64(baseline) andosx_arm64(extra);osx_amd64, other ARM, and wasm are not validated yet. Live Salesforce TLS on macOS is unverified (useSSL_CERT_FILE). - Not yet on community-extensions, and the project is pre-1.0.