Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Integration

EXCEEDED_ID_LIMIT: record limit reached

You hit one of Salesforce's hard caps on a record-related operation: too many sharing rows on one record, too many child records on one parent in a master-detail, too many junction-object rows. The fix depends on which cap, but the error always names the offending sObject.

Also seen asEXCEEDED_ID_LIMIT·record limit reached·EXCEEDED_ID_LIMIT: record limit

A bulk-sync job that copies Account records from Salesforce to a data warehouse uses Bulk API 2.0 to extract everything in a single query. The job has been working for months. Today it returns EXCEEDED_ID_LIMIT: record limit reached. The query is straightforward: SELECT Id, Name, Industry FROM Account. Nothing changed in the SOQL. The Account table just crossed a threshold the integration wasn't built for.

What the platform is checking

Salesforce enforces several limits on how many record IDs can flow through a single operation. The exact limit depends on the API surface in play.

For WHERE Id IN (:ids) queries via REST or SOAP, the platform allows up to 1,000 IDs per query. Send more than that and the query fails before execution. For Bulk API queries, the result-set size limit is much larger (millions of records), but the platform breaks the result into batches and the client must paginate.

For STANDARDPRICEBOOKENTRY or IN queries with very large IN-lists, the SOQL parser enforces a 4,000-element limit per IN-list. Some objects (Tasks and Events) have lower limits for certain query patterns.

For ContentDocumentLink queries, the platform requires a filter that limits the result set; an unfiltered query can return tens of millions of rows and is rejected.

The EXCEEDED_ID_LIMIT error fires when the operation tries to handle more IDs than the relevant limit allows. The fix varies by which operation is involved.

The broken examples

A REST query that passes too many IDs in the WHERE clause:

ids = ['001xx000003DGb1AAG', '001xx000003DGb2AAG', ...]  # 5000 IDs
soql = "SELECT Id, Name FROM Account WHERE Id IN ('" + "','".join(ids) + "')"
response = requests.get(
    f'{instance_url}/services/data/v60.0/query?q={urllib.parse.quote(soql)}',
    headers={'Authorization': f'Bearer {token}'}
)
# Returns: {"errorCode":"EXCEEDED_ID_LIMIT","message":"record limit reached"}

The IN-list has 5,000 elements. The platform rejects the query because the IN-list exceeds 1,000.

A Bulk API extract that exceeds the bulk-query result limit:

job_id = create_bulk_query_job("SELECT Id, Name FROM Account")
# When the job runs, returns failed status with EXCEEDED_ID_LIMIT

If Account has more than the bulk-query result-size limit (50 million for Bulk API 2.0), the job fails. This is rare for most orgs but happens at large enterprises.

A Composite API request that tries to delete too many records at once:

ids = ['003xx000003GAb1AAG', ...]  # 1500 contact IDs
response = requests.delete(
    f'{instance_url}/services/data/v60.0/composite/sobjects?ids={",".join(ids)}',
    headers={'Authorization': f'Bearer {token}'}
)
# Returns: {"errorCode":"EXCEEDED_ID_LIMIT","message":"record limit reached"}

The Composite Sobjects endpoint limits to 200 records per call. 1,500 exceeds that.

The fix, three paths

Batch the IDs into smaller chunks. When the operation accepts up to N IDs and you have more, split into chunks of N or fewer and call the operation per chunk.

def chunked(iterable, size):
    for i in range(0, len(iterable), size):
        yield iterable[i:i+size]

all_results = []
for chunk in chunked(ids, 1000):
    soql = "SELECT Id, Name FROM Account WHERE Id IN ('" + "','".join(chunk) + "')"
    response = requests.get(f'{instance_url}/services/data/v60.0/query?q={urllib.parse.quote(soql)}', headers=headers)
    all_results.extend(response.json()['records'])

The chunking strategy works for queries, deletes, updates, and any operation that has a per-call ID limit. Pick the chunk size based on the most restrictive limit in your call path.

Use a different query pattern. Sometimes an IN-list is the wrong tool. A query that filters by a field other than Id, or uses a relationship, can return the same results without the ID-list pattern.

# Instead of: WHERE Id IN ([5000 ids])
# If the IDs share a common attribute, query by that:
soql = "SELECT Id, Name FROM Account WHERE Source__c = 'WebForm' AND CreatedDate >= LAST_N_DAYS:7"

The rewrite eliminates the IN-list entirely. Use this pattern when the IDs were originally selected by some criterion that's expressible directly in SOQL.

Switch to Bulk API for large operations. The REST and SOAP APIs are optimized for small, transactional calls. The Bulk API is designed for large operations and has different limits. A delete of 100,000 records is comfortable in Bulk API; the same operation via Composite REST requires 500 separate calls.

# Pseudocode for Bulk API 2.0 delete
job_id = create_bulk_job(operation='delete', sobject='Account')
upload_csv(job_id, account_ids_csv)
close_job(job_id)
wait_for_completion(job_id)
results = fetch_job_results(job_id)

The Bulk API runs asynchronously, returns a job ID, and the client polls for completion. The mental model is different (jobs, not requests), but the throughput is much higher.

The fixed example

A chunked-query helper that respects the 1,000-ID limit:

import urllib.parse
import requests

class SalesforceQuery:
    CHUNK_SIZE = 1000

    def __init__(self, instance_url, token):
        self.instance_url = instance_url
        self.headers = {'Authorization': f'Bearer {token}'}

    def query_by_ids(self, sobject, ids, fields):
        all_records = []
        for chunk in self._chunked(ids, self.CHUNK_SIZE):
            quoted = "','".join(chunk)
            soql = f"SELECT {','.join(fields)} FROM {sobject} WHERE Id IN ('{quoted}')"
            url = f'{self.instance_url}/services/data/v60.0/query?q={urllib.parse.quote(soql)}'
            resp = requests.get(url, headers=self.headers)
            resp.raise_for_status()
            all_records.extend(resp.json()['records'])
        return all_records

    @staticmethod
    def _chunked(items, size):
        for i in range(0, len(items), size):
            yield items[i:i+size]

# Usage
sf = SalesforceQuery(INSTANCE_URL, TOKEN)
records = sf.query_by_ids('Account', large_id_list, ['Id', 'Name', 'Industry'])

The helper transparently handles any list size. Callers pass in 50 IDs or 50,000; the helper chunks and aggregates. The caller doesn't need to know the limit.

API surfaces and their limits

A summary of common operations and their limits:

  • REST query (/services/data/v60.0/query): SOQL IN-list up to 1,000 elements typically (the parser limit is 4,000 but other constraints often surface first).
  • REST composite sobjects: 200 records per call.
  • REST composite tree: 200 records per call, single object type.
  • REST composite: 25 subrequests per call.
  • REST sobjects/[id]/blob: 1 record per call (single-document operations).
  • SOAP create/update/upsert/delete: 200 records per call (the SOAP standard for DML).
  • Bulk API 1.0: 10,000 records per batch, 100 million records per 24-hour rolling window.
  • Bulk API 2.0: 150 million records per 24-hour window for ingest; query jobs return results in batches the client paginates.

Pick the API that matches the operation size. A 50-record update fits REST. A 50,000-record load fits Bulk.

When IN-list patterns are unavoidable

Some workflows genuinely need ID-based filtering. A reconciliation job that compares Salesforce records to an external system's view often needs to query by ID because the matching criterion is external.

For these cases, the chunking pattern above is the right approach. Wrap it in a utility that callers don't need to think about.

For very large ID sets (hundreds of thousands), consider an alternative architecture: bulk-export everything from Salesforce, do the comparison locally, then bulk-import only the discrepancies. The comparison doesn't have to round-trip per ID.

Edge case: PK chunking

For very large queries (extracting millions of records), Bulk API supports PK chunking. The platform splits the query result by primary-key range and returns each chunk separately. The client doesn't need to chunk manually; the platform does it.

PK chunking is enabled via a header on the bulk-query job:

Sforce-Enable-PKChunking: chunkSize=100000

The job returns results in chunks of approximately 100,000. The client downloads each chunk separately. For queries that would otherwise exceed the Bulk API's per-batch limit, PK chunking is the standard solution.

Edge case: Tooling API queries

Some Tooling API queries (against ApexClass, ApexLog, etc.) have lower limits than the data API. Querying ApexLog with WHERE Id IN (1000 ids) can fail because the Tooling API enforces a stricter limit.

Check the API documentation for the specific endpoint you're calling. The numbers in this article are typical; specific endpoints may differ.

Edge case: SOQL OFFSET limits

If you're paginating a large result set with LIMIT n OFFSET m, Salesforce caps OFFSET at 2,000. Beyond that, the platform errors out (different error message than EXCEEDED_ID_LIMIT, but the same family of "too many records" problems).

The fix is to paginate by primary key or by a sortable field rather than by OFFSET. Query in batches ordered by Id ASC, remember the last-seen Id, and use WHERE Id > :last:

last_id = '000000000000000'
batch = sf.query(f"SELECT Id, Name FROM Account WHERE Id > '{last_id}' ORDER BY Id LIMIT 2000")
while batch:
    process(batch)
    last_id = batch[-1]['Id']
    batch = sf.query(f"SELECT Id, Name FROM Account WHERE Id > '{last_id}' ORDER BY Id LIMIT 2000")

The pattern scales to arbitrary result sizes because each query only returns 2,000 rows and the cursor moves forward each iteration.

Diagnosing in production

When EXCEEDED_ID_LIMIT fires:

  1. Identify which API endpoint was called.
  2. Count the IDs involved (the request body or query string).
  3. Look up the limit for that endpoint.
  4. If the limit is the issue, switch to chunked calls or to a higher-throughput API (Bulk).
  5. If the limit shouldn't apply (the count is well under the limit), inspect the actual request; sometimes the integration accidentally sends duplicate IDs or includes a leading/trailing whitespace that the parser counts.

Most incidents resolve in an hour once the right API surface is identified.

Test patterns

A test that exercises the chunking logic with various input sizes:

def test_chunked_query_handles_5000_ids():
    ids = [f'001xx000003DGb{i:04d}AAG' for i in range(5000)]
    records = sf.query_by_ids('Account', ids, ['Id', 'Name'])
    assert len(records) <= 5000  # Some may not exist
    assert all('Id' in r for r in records)

def test_chunked_query_handles_empty_input():
    records = sf.query_by_ids('Account', [], ['Id', 'Name'])
    assert records == []

def test_chunked_query_handles_exactly_1000():
    ids = [f'001xx000003DGb{i:04d}AAG' for i in range(1000)]
    records = sf.query_by_ids('Account', ids, ['Id', 'Name'])
    # Exactly one chunk; verify no off-by-one

The boundary cases (0, exactly the chunk size, one over, several chunks) catch off-by-one bugs.

A subtle case: orderly ID generation

Salesforce IDs are not strictly sequential, but they are time-ordered by creation timestamp. Sorting records by Id and paginating with WHERE Id > :lastSeen ORDER BY Id traverses records in roughly chronological order. The order isn't guaranteed for records created in the same millisecond, but it's stable enough for nightly extracts.

For workloads that need exactly-once processing of a growing record set (an event-stream-style consumer), the Id-based cursor is more reliable than relying on CreatedDate because the Id is unique while CreatedDate ties for records created in the same millisecond.

last_seen = read_cursor_from_state()
records = sf.query(f"SELECT Id, Name FROM Lead WHERE Id > '{last_seen}' ORDER BY Id LIMIT 2000")
while records:
    process(records)
    write_cursor_to_state(records[-1]['Id'])
    records = sf.query(f"SELECT Id, Name FROM Lead WHERE Id > '{last_seen}' ORDER BY Id LIMIT 2000")

The state-file pattern survives integration restarts. If the process dies mid-batch, the next run picks up at the saved cursor and re-processes the partial batch (idempotent processing handles the overlap).

A subtle case: cross-object ID lists

A common operation is "for each Account in this list, find all related Contacts". A naive implementation passes Account IDs to a Contact query:

account_ids = [account['Id'] for account in accounts]  # 5000 accounts
soql = "SELECT Id, AccountId FROM Contact WHERE AccountId IN ('" + "','".join(account_ids) + "')"

The IN-list of 5,000 Account IDs hits the same limit even though we're querying Contact, not Account. The chunking pattern applies: split the Account ID list into chunks of 1,000, run one Contact query per chunk, aggregate.

For large parent-child fan-outs, consider running the query in the opposite direction. Instead of "find Contacts for these 5,000 Accounts", run "find Contacts created in the last 7 days and join to Accounts client-side". The criterion-based query often returns the same useful data without ID-list pagination.

Defensive habits

Wrap every limit-bound operation in a helper that respects the limit. Don't sprinkle chunking code throughout the codebase; centralize it. The next developer changing the integration inherits the chunking behavior automatically.

Document each API surface your integration uses, with the relevant limits. A README that says "this integration uses REST query (1,000-ID IN-list limit), composite sobjects (200-record limit), and Bulk API ingest (no per-call limit, 150M/day rolling)" is gold during incident response.

Monitor request sizes. A spike in the number of IDs per request is an early warning that you're approaching a limit. Alert on requests that get close to the limit, not just on requests that exceed it. The early warning gives time to apply chunking before the integration breaks.

Prefer Bulk API for any operation involving more than a few thousand records. The latency is higher (jobs are asynchronous) but the throughput and reliability are much better than chained REST calls. For one-shot loads of millions of rows, Bulk API is the only sensible choice.

Quick recovery checklist

When EXCEEDED_ID_LIMIT fires:

  1. Identify the API endpoint and the request size.
  2. Apply chunking, switch APIs, or rewrite the query as appropriate.
  3. Retry the operation in chunks.
  4. After resolution, audit the integration for other places where the same pattern might fail in the future.

Most incidents resolve within an hour. The longer ones uncover scaling issues that have been quietly worsening for months and need a more substantial redesign.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Integration errors