Apex DML error

INVALID_CROSS_REFERENCE_KEY: invalid cross reference id

"INVALID_CROSS_REFERENCE_KEY: invalid cross reference id" — what it means and how to fix it

A field on the row you are inserting or updating holds a record Id that the platform will not accept for that field. Nearly always this is the wrong object type, a record that no longer exists, or an Id copied from another org. It is almost never a 15-versus-18-character problem — both forms are valid.

Quick answer

Read the field name in the brackets at the end of the message, then read the first three characters of the Id you put in it. That prefix identifies the SObject the Id belongs to. If it doesn't match what the lookup points at, you found your bug. If it does match, the record has been deleted or was never in this org.

What the error means, mechanically

A "cross reference" is any field whose value is a pointer to another record: lookups and master-detail fields, OwnerId, RecordTypeId, ParentId, polymorphic fields like WhatId and WhoId. Before saving a row, Salesforce resolves every one of those pointers. The save is rejected with INVALID_CROSS_REFERENCE_KEY when a pointer cannot be resolved to a record that is valid for that specific field.

Two independent checks must both pass: the target record must exist, and its SObject type must be one the field is allowed to reference. Failing either produces the same message, which is why the error feels vague.

text
Insert failed. First exception on row 0; first error:
  INVALID_CROSS_REFERENCE_KEY, invalid cross reference id: [Project__c]

The bracketed value is the field that failed — the single most useful part of the message, and the part most people skip past. When it's empty ([]), the failing pointer is a system field rather than one of yours, most often OwnerId or RecordTypeId.

The causes, ranked by how often they actually happen

1. An Id of the wrong SObject type

Every Salesforce Id starts with a three-character key prefix that identifies its object: 001 Account, 003 Contact, 006 Opportunity, 00Q Lead, 005 User, 00G Group, and a per-org-assigned a0X-style prefix for each custom object. Put a 003… in a lookup that references Account and the platform rejects the row.

In real code this happens when two Id variables are swapped in a long constructor, when a map is keyed by the wrong parent, or when a generic helper passes through an Id it never type-checked (Apex's Id type carries no object type at compile time).

apex
// contactId is a 003… Id; Project__c.Account__c references Account (001…)
Project__c p = new Project__c(Name = 'Migration', Account__c = contactId);
insert p;   // INVALID_CROSS_REFERENCE_KEY, invalid cross reference id: [Account__c]

// Check the type before you assign it — getSObjectType() reads the key prefix
if (contactId.getSObjectType() != Account.SObjectType) {
  throw new IllegalArgumentException('Expected an Account Id, got ' + contactId.getSObjectType());
}

Id.getSObjectType() is the cheap guard. It resolves the prefix without a query, so you can assert the type at the boundary of any method that accepts a bare Id.

2. A hardcoded Id — from another org, or a refreshed sandbox

The classic. A RecordTypeId, a queue Id, a "special" Account Id pasted into a constant or a Custom Setting. Record Ids are org-specific: the value is perfectly well-formed and simply does not exist here. Sandbox refreshes reset the same way — the Ids from last month's copy are gone.

apex
// Breaks in every org except the one it was copied from
Case c = new Case(RecordTypeId = '0125g000000XYZAAA');

// Resolve by developer name at runtime instead
Id supportRt = Schema.SObjectType.Case
  .getRecordTypeInfosByDeveloperName()
  .get('Support')
  .getRecordTypeId();
Case c = new Case(RecordTypeId = supportRt);

The same applies to queues: look them up with [SELECT Id FROM Group WHERE DeveloperName = 'Escalations' AND Type = 'Queue'] rather than storing the Id.

3. The parent record was deleted

The Id was valid when you read it and isn't any more — the parent was deleted earlier in the same transaction, or sits in the Recycle Bin. A lookup to a deleted record fails on save even though the row still physically exists until the bin is purged. Re-query the parent, or set the field to null.

4. RecordTypeId that isn't valid for this object or this user

Two distinct failures wear the same message: a record type that belongs to a different object, and a record type not assigned to the running user's profile. The second one is nasty because it passes in your admin session and fails for everyone else — and fails in tests the moment you wrap the DML in System.runAs.

5. OwnerId pointing at the wrong kind of entity

OwnerId accepts a User (005…) on every object, and a Queue (00G…) only on objects with queue support enabled. Assigning a Group Id to an object that doesn't support queues, or an inactive user's Id, produces the error with an empty field name in the brackets.

6. A fake Id built in a test with the wrong prefix

Test utilities that fabricate Ids without inserting records are common and useful. They fail the moment the fabricated prefix doesn't match the field's target object — and since nothing is inserted, there's no record to point at either. Fabricated Ids are safe for in-memory assertions and unsafe for DML.

apex
// Fine for in-memory work, never for DML on a lookup
static Id fakeId(Schema.SObjectType t) {
  return t.getDescribe().getKeyPrefix() + '000000000000'.substring(0, 12);
}

// If the row will be inserted, insert a real parent instead
Account parent = new Account(Name = 'Test Co');
insert parent;
Project__c p = new Project__c(Name = 'Migration', Account__c = parent.Id);
insert p;

What is not the cause: 15 vs 18-character Ids

This is the most common wrong answer to this error. Salesforce accepts both the 15-character case-sensitive form and the 18-character case-insensitive form interchangeably in SOQL filters and in DML. Passing a 15-character Id into a lookup does not produce INVALID_CROSS_REFERENCE_KEY.

Where the two forms genuinely bite is comparison: a 15-character string and its 18-character equivalent are not equal as Strings, so map lookups and Set membership silently miss. Cast to Id and compare as Id, and the problem disappears. That's a different bug with different symptoms — usually a wrong result, not a DML failure.

The part of the Id that matters for this error is the first three characters. The length is noise.

Related error codes that are easy to confuse

  • INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY — the target record exists and is the right type, but the running user cannot access it. Sharing problem, not a pointer problem.
  • ENTITY_IS_DELETED — you are operating on the deleted record itself, rather than pointing at it.
  • MALFORMED_ID — the string isn't a valid Id at all: wrong length, invalid characters, or an empty string where an Id was expected.

Catch wrong-object Ids before you deploy

Nimbus runs your Salesforce Apex tests locally against an embedded PostgreSQL, and its DML layer validates lookup targets the way the platform does: when a value in a custom lookup field carries a key prefix that resolves to an SObject the field doesn't reference, the insert fails with INVALID_CROSS_REFERENCE_KEY instead of being quietly accepted. That's the wrong-object case — cause 1 above, and the one that otherwise survives all the way to a deploy.

Being precise about the scope: this check is deliberately conservative. It applies to lookup fields defined in your own project metadata, and it skips standard polymorphic fields (OwnerId, WhatId) and managed-package lookups, whose local describes list fewer valid targets than the platform actually accepts. An Id whose prefix Nimbus cannot resolve is accepted rather than rejected, so you don't get false failures on org-real data.

bash
# Run the failing test locally
nimbus test "ProjectServiceTest.*"

# See the DML that failed, the row, and the operations that preceded it
nimbus explain ProjectServiceTest.testCreateProject