Salesforce Bulk API 2.0 is the asynchronous, high-volume data processing API for Salesforce CRM and Platform. It was introduced to simplify the developer experience of the original Bulk API (1.0) by eliminating manual batch management — you submit data and Salesforce handles the chunking, batching, retries, and parallel processing internally. It shares the same REST API framework and OAuth authentication as the Salesforce REST API.
This card covers Bulk API 2.0 as available in API v62.0 (Spring '26) for Enterprise, Unlimited, Performance, and Developer editions. Professional edition has limited Bulk API access. Essentials edition does not include Bulk API. The limits documented here apply to Salesforce Production and Sandbox orgs, though sandbox orgs may have lower limits.
| Property | Value |
|---|---|
| Vendor | Salesforce |
| System | Salesforce CRM / Platform (API v62.0, Spring '26) |
| API Surface | Bulk API 2.0 |
| Current API Version | v62.0 |
| Editions Covered | Enterprise, Unlimited, Performance, Developer |
| Deployment | Cloud |
| API Docs | Bulk API 2.0 Developer Guide |
| Status | GA (Generally Available) |
Bulk API 2.0 supports two job types — ingest (write operations) and query (read operations). Here is where Bulk API 2.0 fits within the Salesforce API ecosystem:
| API Surface | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| REST API | HTTPS/JSON | Individual record CRUD, <2K records | 200 (composite), 2,000 (query) | 100K calls/24h (Enterprise) | Yes | No |
| Bulk API 2.0 | HTTPS/CSV or JSON | ETL, data migration, >2K records | 150M records per file | 100M records/24h | No (async) | Yes |
| Bulk API 1.0 | HTTPS/CSV, XML, JSON | Serial processing, XML requirement | 10,000 per batch | 15,000 batches/24h | No (async) | Yes |
| SOAP API | HTTPS/XML | Metadata operations, legacy systems | 2,000 per call | Shared with REST | Yes | No |
| Composite API | HTTPS/JSON | Multi-object transactions | 25 subrequests | Shared with REST | Yes | No |
| Streaming API | Bayeux/CometD | Real-time notifications (CDC, PushTopics) | N/A | Edition-dependent | Yes | N/A |
| Operation | Description | External ID Required? | Notes |
|---|---|---|---|
insert | Creates new records | No | Fails on duplicate if no external ID |
update | Modifies existing records | No | Requires Salesforce record ID in CSV |
upsert | Insert or update based on external ID | Yes | Specify externalIdFieldName on job creation |
delete | Moves records to Recycle Bin | No | Requires Salesforce record ID |
hardDelete | Permanently deletes (bypasses Recycle Bin) | No | Requires "Bulk API Hard Delete" permission |
| Operation | Description | Notes |
|---|---|---|
query | Executes SOQL, returns active records | Standard query semantics |
queryAll | Executes SOQL, includes soft-deleted and archived records | Includes Recycle Bin records (up to 15-day retention) |
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max upload size per job | 150 MB (base64 encoded) | Ingest jobs | Keep unencoded CSV/JSON under 100 MB |
| Max records per file | 150,000,000 | Ingest jobs | Practical limit — files rarely approach this |
| Internal batch size | 10,000 records | Ingest jobs (internal) | Salesforce auto-chunks; not configurable |
| Query result chunk size | 100,000-250,000 records | Query jobs (internal) | Salesforce auto-chunks query output |
| Max query result size | 15 GB | Query jobs | Per single query job |
| Query result expiry | 7 days | Query jobs | Must download results within 7 days of job completion |
| Batch processing timeout | 5 minutes | Per internal batch | Batch paused/requeued if exceeded; retried up to 10 times |
| Max fields per record | 5,000 | All operations | Standard Salesforce object limit |
| Limit Type | Value | Window | Edition Differences |
|---|---|---|---|
| Total records processed | 100,000,000 | 24h rolling | Same across all editions with Bulk API access |
| Concurrent ingest jobs | 25 | Per org | Shared between Bulk API 2.0 and 1.0 |
| Concurrent query jobs | 25 | Per org | Shared between Bulk API 2.0 and 1.0 |
| Total jobs in any state | 100,000 | Per org | Delete completed/aborted jobs to free capacity |
| Bulk API 1.0 batches (if using v1) | 15,000 | 24h rolling | Only applies to Bulk API 1.0 |
Each internal batch of 10,000 records processes in transactions of 200 records. Standard Apex governor limits apply per 200-record chunk if triggers or flows fire:
| Limit Type | Per-Transaction Value | Notes |
|---|---|---|
| SOQL queries | 100 | Includes queries from triggers — cascading triggers consume from same pool |
| DML statements | 150 | Each insert/update/delete counts as 1 |
| Callouts | 100 | HTTP requests to external services within a transaction |
| CPU time | 10,000 ms (sync), 60,000 ms (async) | Exceeded = transaction abort |
| Heap size | 6 MB (sync), 12 MB (async) | Large record processing can hit this |
| Total email invocations | 10 | Workflow email actions per transaction |
Bulk API 2.0 uses the same authentication as Salesforce REST API — all standard OAuth 2.0 flows are supported. This is a key improvement over Bulk API 1.0, which required SOAP-based session ID authentication.
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 JWT Bearer | Server-to-server, no user context | Session timeout (default 2h) | New JWT per request | Recommended for integrations |
| OAuth 2.0 Web Server | User-context operations, interactive apps | Access: 2h, Refresh: until revoked | Yes | Requires callback URL |
| OAuth 2.0 Client Credentials | Machine-to-machine (no user) | Session timeout | Yes | Available since Winter '23 |
| Username-Password | Legacy, testing only | Session timeout | No | Do NOT use in production — no MFA support |
Sforce-Limit-Info response header for remaining API calls. [src4]concurrencyMode: serial.START — User needs to bulk-process data in Salesforce
├── What's the operation?
│ ├── Ingest (insert/update/upsert/delete/hardDelete)
│ │ ├── Data volume < 2,000 records?
│ │ │ ├── YES → Use REST API (simpler, synchronous)
│ │ │ └── NO ↓
│ │ ├── Need serial processing (record lock issues)?
│ │ │ ├── YES → Use Bulk API 1.0 (concurrencyMode: serial)
│ │ │ └── NO ↓
│ │ ├── Data volume < 150 MB per file?
│ │ │ ├── YES → Single Bulk API 2.0 job
│ │ │ └── NO → Split into multiple jobs (each ≤100 MB unencoded)
│ │ └── Need XML format?
│ │ ├── YES → Use Bulk API 1.0
│ │ └── NO → Bulk API 2.0 with CSV or JSON
│ └── Query (extract data)
│ ├── Data volume < 2,000 records?
│ │ ├── YES → Use REST API SOQL query
│ │ └── NO ↓
│ ├── Need soft-deleted/archived records?
│ │ ├── YES → Bulk API 2.0 queryAll operation
│ │ └── NO → Bulk API 2.0 query operation
│ └── Result set > 15 GB?
│ ├── YES → Split SOQL with WHERE clause date ranges
│ └── NO → Single Bulk API 2.0 query job
├── CSV delimiter requirements?
│ ├── Standard comma → Default (no config needed)
│ └── Other (pipe, semicolon, tab, caret, backquote) → Set columnDelimiter
└── Error tolerance?
├── Zero-loss → Poll status + retrieve failedResults + reprocess
└── Best-effort → Poll status, log failures, move on
| Operation | Method | Endpoint | Payload | Notes |
|---|---|---|---|---|
| Create ingest job | POST | /services/data/v62.0/jobs/ingest | JSON (job config) | Returns id and contentUrl |
| Upload data | PUT | /services/data/v62.0/jobs/ingest/{jobId}/batches | CSV or JSON data | Content-Type: text/csv |
| Close job (start processing) | PATCH | /services/data/v62.0/jobs/ingest/{jobId} | {"state":"UploadComplete"} | Triggers async processing |
| Check job status | GET | /services/data/v62.0/jobs/ingest/{jobId} | N/A | Returns state + record counts |
| Get successful results | GET | /services/data/v62.0/jobs/ingest/{jobId}/successfulResults | N/A | CSV with sf__Id, sf__Created |
| Get failed results | GET | /services/data/v62.0/jobs/ingest/{jobId}/failedResults | N/A | CSV with sf__Error column |
| Get unprocessed records | GET | /services/data/v62.0/jobs/ingest/{jobId}/unprocessedrecords | N/A | Records not attempted |
| Abort job | PATCH | /services/data/v62.0/jobs/ingest/{jobId} | {"state":"Aborted"} | Stops processing |
| Delete job | DELETE | /services/data/v62.0/jobs/ingest/{jobId} | N/A | Frees job count quota |
| List all ingest jobs | GET | /services/data/v62.0/jobs/ingest | N/A | Filter by isPkChunkingEnabled, jobType |
| State | Meaning | Transitions To |
|---|---|---|
Open | Accepting data uploads | UploadComplete, Aborted |
UploadComplete | Data received, queued for processing | InProgress |
InProgress | Salesforce is processing internal batches | JobComplete, Failed, Aborted |
JobComplete | All records processed (some may have failed individually) | — |
Failed | Job-level failure (e.g., 10 batch retries exhausted) | — |
Aborted | Manually or automatically aborted | — |
Obtain an access token using the JWT Bearer flow for server-to-server integration. [src4]
curl -X POST https://login.salesforce.com/services/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=${JWT_TOKEN}"
Verify: curl -H "Authorization: Bearer ${ACCESS_TOKEN}" ${INSTANCE_URL}/services/data/v62.0/limits → expected: JSON with DailyBulkV2QueryJobs field.
Define the job parameters: object, operation, content type. [src1]
curl -X POST ${INSTANCE_URL}/services/data/v62.0/jobs/ingest \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"object":"Contact","operation":"upsert","externalIdFieldName":"External_ID__c","contentType":"CSV"}'
Verify: Response includes "state": "Open" and a valid id field.
Send CSV data as the request body. First line must be field API names. [src1]
curl -X PUT ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID}/batches \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: text/csv" \
--data-binary @contacts.csv
Verify: HTTP 201 Created response.
Signal to Salesforce that all data has been uploaded. [src1]
curl -X PATCH ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID} \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"state": "UploadComplete"}'
Verify: Response shows "state": "UploadComplete".
Check status periodically. Recommended: 30s for <100K records, 60s for larger jobs. [src1]
curl ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID} \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# Response: {"state":"JobComplete","numberRecordsProcessed":50000,"numberRecordsFailed":12,...}
Verify: state is JobComplete. Check numberRecordsFailed.
Download failed records CSV, fix issues, submit new job with corrected records. [src1]
curl ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID}/failedResults \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-o failed_records.csv
Verify: Open failed_records.csv — each row includes sf__Error column.
# Input: CSV file path, Salesforce credentials (instance_url, access_token)
# Output: Job completion summary with success/failure counts
import requests
import time
def bulk_upsert(instance_url, access_token, object_name, csv_path,
external_id_field, api_version="v62.0"):
base = f"{instance_url}/services/data/{api_version}/jobs/ingest"
headers = {"Authorization": f"Bearer {access_token}"}
# Create job
job_resp = requests.post(base, headers={**headers, "Content-Type": "application/json"},
json={"object": object_name, "operation": "upsert",
"externalIdFieldName": external_id_field,
"contentType": "CSV", "lineEnding": "LF"})
job_resp.raise_for_status()
job_id = job_resp.json()["id"]
# Upload CSV
with open(csv_path, "rb") as f:
requests.put(f"{base}/{job_id}/batches",
headers={**headers, "Content-Type": "text/csv"}, data=f).raise_for_status()
# Close job
requests.patch(f"{base}/{job_id}",
headers={**headers, "Content-Type": "application/json"},
json={"state": "UploadComplete"}).raise_for_status()
# Poll until complete
while True:
info = requests.get(f"{base}/{job_id}", headers=headers).json()
if info["state"] in ("JobComplete", "Failed", "Aborted"):
break
time.sleep(30)
# Retrieve failed records if any
failed = info.get("numberRecordsFailed", 0)
if failed > 0:
failed_csv = requests.get(f"{base}/{job_id}/failedResults", headers=headers).text
print(f"{failed} failed records:\n{failed_csv[:2000]}")
return {"job_id": job_id, "state": info["state"],
"processed": info.get("numberRecordsProcessed", 0), "failed": failed}
// Input: Salesforce credentials, SOQL query string
// Output: Array of CSV result chunks (handles locator-based pagination)
async function bulkQuery(instanceUrl, accessToken, soql, apiVersion = 'v62.0') {
const base = `${instanceUrl}/services/data/${apiVersion}/jobs/query`;
const headers = { 'Authorization': `Bearer ${accessToken}` };
// Create query job
const jobResp = await fetch(base, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ operation: 'query', query: soql })
});
const { id: jobId } = await jobResp.json();
// Poll for completion
let state = 'UploadComplete';
while (!['JobComplete', 'Failed', 'Aborted'].includes(state)) {
await new Promise(r => setTimeout(r, 10000));
const info = await (await fetch(`${base}/${jobId}`, { headers })).json();
state = info.state;
}
if (state !== 'JobComplete') throw new Error(`Query job ${state}`);
// Retrieve results with locator-based pagination
let allRecords = [], locator = null;
do {
const url = locator ? `${base}/${jobId}/results?locator=${locator}`
: `${base}/${jobId}/results`;
const resp = await fetch(url, { headers });
locator = resp.headers.get('Sforce-Locator');
if (locator === 'null') locator = null;
allRecords.push(await resp.text());
} while (locator);
return allRecords;
}
# Input: Valid access token and instance URL
# Output: Job ID and processing status
# 1. Create job
JOB_ID=$(curl -s -X POST ${INSTANCE_URL}/services/data/v62.0/jobs/ingest \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"object":"Account","operation":"insert","contentType":"CSV"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# 2. Upload CSV
curl -X PUT ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID}/batches \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: text/csv" \
-d 'Name,Industry,NumberOfEmployees
Acme Corp,Technology,500
Beta Inc,Finance,200'
# 3. Close job and 4. Check status
curl -X PATCH ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID} \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" -d '{"state":"UploadComplete"}'
| Property | Value | Notes |
|---|---|---|
| Header row | Required — field API names | Not display labels |
| Encoding | UTF-8 | BOM characters cause job failure |
| Line endings | LF or CRLF | Configurable via lineEnding param |
| Column delimiter | Comma (default) | Options: COMMA, BACKQUOTE, CARET, PIPE, SEMICOLON, TAB |
| Null values | #N/A | Empty string clears field (not null) |
| Boolean values | true / false | Case-insensitive |
| Date format | YYYY-MM-DD | ISO 8601 |
| DateTime format | YYYY-MM-DDThh:mm:ss.sssZ | UTC recommended |
| Number format | No thousands separator | 1234.56 not 1,234.56 |
| Escaping | Double-quote fields with delimiters | Escape quotes by doubling: "" |
| Code | Meaning | Cause | Resolution |
|---|---|---|---|
| DUPLICATE_VALUE | Duplicate external ID or unique field | Record with same value exists | Use upsert instead of insert |
| REQUIRED_FIELD_MISSING | Mandatory field not in CSV | Required field omitted or null | Ensure all required fields have values |
| FIELD_CUSTOM_VALIDATION_EXCEPTION | Validation rule failed | Data violates custom validation rule | Fix data or deactivate rule during migration |
| UNABLE_TO_LOCK_ROW | Row lock contention | Concurrent updates to same record | Use Bulk API 1.0 serial mode; add retry with jitter |
| INVALID_FIELD | Field doesn't exist or isn't writable | Wrong API name or field-level security | Verify field API names and permissions |
| STORAGE_LIMIT_EXCEEDED | Org data storage full | Org exceeded storage allocation | Free storage or purchase additional |
| REQUEST_LIMIT_EXCEEDED | Daily Bulk API limit hit | 100M record daily limit exceeded | Wait for 24h rolling window |
| InvalidBatch | Job-level failure after 10 retries | Internal batch failed repeatedly | Review org automation complexity |
Strip BOM before upload — in Python: open(file, encoding='utf-8-sig'). [src7]Disable non-critical triggers during bulk loads using a custom setting flag. [src6]Implement token refresh logic in polling loop. [src4]Always call GET /unprocessedrecords after any non-JobComplete state. [src1]Validate headers against describe result before uploading. [src1]# BAD — hammers the API, consumes quota, does not speed up processing
while True:
status = check_status(job_id)
if status['state'] == 'JobComplete': break
time.sleep(1) # 3,600 API calls/hour wasted
# GOOD — starts at 10s, backs off to 60s max
wait = 10
while True:
status = check_status(job_id)
if status['state'] in ('JobComplete', 'Failed', 'Aborted'): break
time.sleep(min(wait, 60))
wait = min(wait * 1.5, 60)
# BAD — exceeds 150 MB limit, job creation fails
with open('huge_export.csv', 'rb') as f:
requests.put(f"{base}/{job_id}/batches", data=f)
# GOOD — split at ~90 MB (safe margin below 150 MB base64 limit)
def chunk_csv(input_path, max_bytes=90_000_000):
chunks, current_chunk, current_size = [], [], 0
with open(input_path, 'r', encoding='utf-8-sig') as f:
reader = csv.reader(f)
header = ','.join(next(reader)) + '\n'
for row in reader:
line = ','.join(row) + '\n'
if current_size + len(line.encode()) > max_bytes and current_chunk:
chunks.append(header + ''.join(current_chunk))
current_chunk, current_size = [], 0
current_chunk.append(line)
current_size += len(line.encode())
if current_chunk: chunks.append(header + ''.join(current_chunk))
return chunks # Submit each as separate job
# BAD — assumes JobComplete means 100% success
if status['state'] == 'JobComplete':
print("All done!") # Could have thousands of failed records
# GOOD — explicitly handle partial success
if status['state'] == 'JobComplete':
failed = status.get('numberRecordsFailed', 0)
if failed > 0:
failed_csv = get_failed_results(job_id)
save_for_retry(failed_csv)
alert_team(f"Bulk job {job_id}: {failed} records failed")
Load-test against a full-copy sandbox with production data volumes. [src2]1,234 in CSV is parsed as two columns by the comma delimiter. Fix: Never use thousands separators — format as 1234; or use PIPE delimiter. [src1]Parse and log on every response; alert when under 20% remaining. [src2]DELETE completed jobs after retrieving results. [src2]Define an external ID field and use upsert for idempotent operations. [src1]Pin API version (e.g., v62.0); test new versions before upgrading. [src1]# Check API usage / remaining Bulk API limits
curl -s ${INSTANCE_URL}/services/data/v62.0/limits \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
| python3 -c "import sys,json; d=json.load(sys.stdin); [print(f'{k}: {v}') for k,v in d.items() if 'Bulk' in k or 'Daily' in k]"
# List all ingest jobs (most recent first)
curl -s "${INSTANCE_URL}/services/data/v62.0/jobs/ingest" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# Check specific job status
curl -s ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID} \
-H "Authorization: Bearer ${ACCESS_TOKEN}" | python3 -m json.tool
# Test authentication
curl -s -o /dev/null -w "%{http_code}" ${INSTANCE_URL}/services/data/v62.0/ \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
# Expected: 200
# Describe target object fields
curl -s ${INSTANCE_URL}/services/data/v62.0/sobjects/Contact/describe \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
| python3 -c "import sys,json; [print(f'{f[\"name\"]:40} {f[\"type\"]:15}') for f in json.load(sys.stdin)['fields']]"
# Delete a completed job to free quota
curl -X DELETE ${INSTANCE_URL}/services/data/v62.0/jobs/ingest/${JOB_ID} \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
| API Version | Release | Status | Key Changes | Notes |
|---|---|---|---|---|
| v62.0 | Spring '26 (Feb 2026) | Current | No breaking changes | Latest GA version |
| v61.0 | Winter '26 (Oct 2025) | Supported | No breaking changes | — |
| v60.0 | Summer '25 (Jun 2025) | Supported | No breaking changes | — |
| v56.0 | Spring '23 (Feb 2023) | Supported | Bulk API 2.0 became default in Data Loader | Major adoption milestone |
| v47.0 | Winter '20 (Oct 2019) | Supported | Query operations added | Previously ingest-only |
| v41.0 | Winter '18 (Oct 2017) | Minimum for Bulk API 2.0 | Initial GA release | Ingest operations only |
Salesforce supports API versions for a minimum of 3 years. Versions are retired in groups — typically 10+ versions at once, with at least 1 year advance notice. No Bulk API 2.0-era versions have been retired as of Spring '26.
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Data migration or ETL of 2,000+ records | Real-time individual record operations needing <1s response | REST API |
| Scheduled nightly/hourly batch synchronization | You need serial processing to avoid lock contention | Bulk API 1.0 (serial mode) |
| Initial data load for new Salesforce org | You need XML format for legacy integration | Bulk API 1.0 (XML support) |
| Large SOQL query exports (>2,000 records) | You need immediate query results (sub-second) | REST API SOQL query |
| Extracting soft-deleted records (queryAll) | You need real-time change notifications | Streaming API / CDC |
| Idempotent bulk upserts via external ID | You need all-or-nothing transactional behavior | Composite API |