The quick version: an Id you put in a lookup, master-detail, or RecordTypeId field is well-formed but points at nothing usable — wrong object type, wrong org, deleted, or an inactive record type. Take the failing Id, read its first three characters, and check that prefix against the object the field actually points to. That one check resolves most cases.
What the platform is telling you
The SOAP API reference defines INVALID_CROSS_REFERENCE_KEY as “a record with the specified ID does not exist in the database.” That undersells it. The Id can exist and you still get this error, because the check is not only “does this record exist” but “is this Id legal in this field.”
Knowing which error you did not get narrows things fast:
| Status code | Means |
|---|---|
MALFORMED_ID |
Not a valid Id at all. Docs: “An ID must be either 15 characters, or 18 characters with a valid case-insensitive extension.” |
INVALID_CROSS_REFERENCE_KEY |
Structurally fine, but not valid for this field |
ENTITY_IS_DELETED |
You’re operating on a record already in the Recycle Bin |
INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY |
The Id is valid and the record exists — the running user just can’t see it |
That last row matters. If the record genuinely exists and is the right type, you have a permissions problem, not a data problem.
Read the key prefix first
Every record Id starts with a three-character prefix identifying its object. The Tooling API documents this on EntityDefinition.KeyPrefix: “the first three digits of the object’s ID, which identify the object type, such as Account or Opportunity.”
Dump the map for your org once and keep it:
sf data query --use-tooling-api \
--query "SELECT QualifiedApiName, KeyPrefix FROM EntityDefinition WHERE KeyPrefix != null ORDER BY KeyPrefix"
Now when a load fails on 0035g00000ABCDEabc in a field named Account__c, you can see in two seconds that 003 is Contact and 001 is Account. Someone mapped the wrong column. This is the most common cause, and it is invisible if you only stare at the Id.
Prefixes worth memorizing: 001 Account, 003 Contact, 005 User, 006 Opportunity, 00Q Lead, 012 RecordType, 500 Case, 00G Group.
Causes, most frequent first
A lookup pointing at the wrong sObject. A CSV column labelled “Account” holding Contact Ids. A mapping file that drifted after someone reordered columns. Or Apex assigning opp.AccountId = con.Id, which compiles cleanly because both variables are typed Id and the compiler cannot tell them apart.
That last one is worth designing against. Id is a loose type. Carrying the sObject instead makes the assignment prove the type:
// Compiles, fails at runtime
Map<String, Id> parentByKey = buildLookup();
opp.AccountId = parentByKey.get(externalKey);
// Better: a missed lookup is a null object you can test
Map<String, Account> accountByKey = buildAccountLookup();
Account parent = accountByKey.get(externalKey);
if (parent == null) {
continue; // handle the miss explicitly
}
opp.Account = parent;
An Id from a different org. Classic in sandbox refreshes and migrations. You built a mapping file in the full sandbox and ran it against production. Ids are unique across all orgs, so nothing collides — the references simply resolve to nothing.
The fix is structural: never migrate on Salesforce Ids. Put an External Id field on each object, populate it in both orgs, and upsert on that. When you must translate Ids, build the map by querying the target org, not from a spreadsheet saved last quarter.
A RecordTypeId that doesn’t belong to the object. A 012 Id is a record type, but a record type belongs to exactly one object. Set an Account record type on a Contact and you get this error even though the record type is perfectly valid where it lives. An inactive record type does the same — the Id resolves, but the platform will not let you assign it.
sf data query --query "SELECT Id, DeveloperName, SobjectType, IsActive FROM RecordType WHERE SobjectType = 'Contact'"
Check IsActive, and check the record type is available to the profile of the user running the load. Availability is per profile, and an integration user’s profile is usually the one nobody updated after a new record type shipped. In Apex, resolve by developer name rather than hardcoding — record type Ids differ between sandbox and production:
Id rtId = Schema.SObjectType.Contact
.getRecordTypeInfosByDeveloperName()
.get('Partner_Contact')
.getRecordTypeId();
The referenced record was deleted. A soft-deleted parent still has an Id, and a spreadsheet built before the delete still carries it. Confirm with ALL ROWS:
SELECT Id, Name, IsDeleted FROM Account WHERE Id = '001xx000003DGb2AAG' ALL ROWS
Either undelete the parent or repoint the child. Note the distinction: referencing a deleted record from a lookup gives you this error, while updating the deleted record itself gives ENTITY_IS_DELETED.
15-character Ids mangled by a spreadsheet. The Object Reference is direct about the risk: 15-character Ids are case-sensitive, and “to Salesforce, 000000000000Abc isn’t the same as 000000000000aBC.” Excel autocorrect or a lowercasing step in an ETL job breaks them silently. The same page notes 18-character Ids are “case-safe, but not case-insensitive”. Change the case by hand and Salesforce detects the suffix no longer matches and errors. Use 18-character Ids and treat them as opaque strings.
Something that looks like a picklist but isn’t. A restricted picklist normally returns INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST (“you specified an invalid or null value for a restricted picklist”) not this error. If you get a cross-reference failure on a dropdown, the field is probably a lookup rendered as a picker, which is common on OwnerId, queue references, and Pricebook2Id.
Diagnosing a load that failed on 40 of 20,000 rows
Get per-row errors rather than one aggregated message. The Apex Developer Guide notes that with allOrNone false, “if a record fails, the remainder of the DML operation can still succeed. Your application can then inspect the rejected records and possibly retry the operation.”
List<Database.SaveResult> results = Database.insert(records, false);
Map<String, Integer> failureCounts = new Map<String, Integer>();
for (Integer i = 0; i < results.size(); i++) {
if (results[i].isSuccess()) {
continue;
}
for (Database.Error e : results[i].getErrors()) {
String key = e.getStatusCode() + ' on ' + String.join(e.getFields(), ',');
Integer seen = failureCounts.get(key);
failureCounts.put(key, seen == null ? 1 : seen + 1);
}
}
System.debug(failureCounts);
getFields() names the offending lookup, which is the whole answer when a record has six of them. Grouping by status code plus field turns 40 scattered failures into “38 are the same mapping bug and 2 are deleted parents.”
For Data Loader, pivot the error CSV on the error column. For Bulk API 2.0, request the failed-results file rather than reading the job summary. The summary gives you the count, the results file gives you the rows.
Preventing it
Migrate on External Ids and upsert, never on Salesforce Ids. This removes the wrong-org, deleted-parent, and mangled-Id causes outright. The cost is a field per object plus the discipline to populate it, and I have never regretted paying it.
Validate mapped Ids before the load rather than during it. A pre-flight check that reads each Id’s three-character prefix and compares it to the target field’s referenced object catches wrong-object mapping in seconds instead of after a 20-minute Bulk API job. Against the EntityDefinition query above that is about ten lines of script.
What I would skip: try/catch wrappers that swallow the error and carry on. You end up with silently missing relationships, which is worse than a failed load because nobody notices for six months.
Frequently asked
Why does the error not tell me which field is wrong?
From a UI save it usually does not. From Apex, Database.Error.getFields() returns the affected field names, and Data Loader’s error file includes them. Route loads through something that surfaces the field rather than guessing.
Is this the same as MALFORMED_ID?
No. MALFORMED_ID means the string is not a valid Id. Wrong length or a bad checksum on an 18-character value. This error means the Id is structurally valid but not acceptable in that field.
Can I get this on a field that is not a lookup?
Yes: RecordTypeId, OwnerId, Pricebook2Id, and queue assignments all behave as cross-references even when the UI presents them as pickers.
Why did the same load work in the sandbox?
Almost always record type Ids or other hardcoded Ids that differ between orgs. Resolve record types by developer name at runtime and keep environment-specific Ids in custom metadata rather than in code or the CSV.
Do I need to convert 15-character Ids to 18 before loading?
You do not need to, but do it anyway. The 18-character form is case-safe, so it survives a trip through a spreadsheet or a tool that normalizes case. The 15-character form does not.
Reference: Core Data Types Used in API Calls (SOAP API Developer Guide), Field Types and Relationships Among Objects (Object Reference), EntityDefinition (Tooling API), and DML Statements vs. Database Class Methods (Apex Developer Guide).
