ENTITY_IS_DELETED: entity is deleted
You tried to update or query a record that's in the recycle bin (soft-deleted). Either undelete it first, query with `ALL ROWS` to include deleted records, or fix the upstream that's pointing at a deleted ID.
Also seen asENTITY_IS_DELETED·entity is deleted·ENTITY_IS_DELETED: entity is deleted
A batch job that updates a nightly snapshot fails with ENTITY_IS_DELETED: entity is deleted. The record id is right; the field values are valid; the running user has every permission. The record was deleted earlier today and now lives in the recycle bin. The Apex transaction tries to update it and the platform refuses.
What the platform is enforcing
Salesforce records have three states: active, soft-deleted, and hard-deleted. Most DML operations only work on active records.
Active: visible in normal queries, editable, deletable. The default state.
Soft-deleted: moved to the recycle bin. The row still exists in the database (with IsDeleted = TRUE) but is hidden from normal queries. Most DML refuses to touch it. The platform keeps soft-deleted records for 15 days before permanent removal, or until you explicitly purge them.
Hard-deleted: removed from the database. The id is no longer valid. Any reference to it returns "record not found" or ENTITY_IS_DELETED depending on context.
ENTITY_IS_DELETED fires when your code holds a record id, the record exists, but it's soft-deleted. The platform refuses to update or upsert it. Insert with the same id is also refused (you can't reinsert a soft-deleted id; you have to undelete it first or use a different id).
The classic broken example
A nightly snapshot job that processes records by id from a cached list:
public class AccountSnapshotJob implements Database.Batchable<sObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id FROM Account_Snapshot__c WHERE Snapshot_Date__c = TODAY'
);
}
public void execute(Database.BatchableContext bc, List<Account_Snapshot__c> snapshots) {
Set<Id> accountIds = new Set<Id>();
for (Account_Snapshot__c s : snapshots) {
accountIds.add(s.Account__c);
}
// The query below will skip soft-deleted accounts, but the
// snapshot rows still hold the soft-deleted ids.
Map<Id, Account> liveById = new Map<Id, Account>(
[SELECT Id, AnnualRevenue FROM Account WHERE Id IN :accountIds]
);
List<Account> toUpdate = new List<Account>();
for (Id aid : accountIds) {
Account a = liveById.get(aid);
if (a == null) continue;
a.Snapshot_Date__c = Date.today();
toUpdate.add(a);
}
// ENTITY_IS_DELETED if any account in toUpdate has been
// soft-deleted in the milliseconds between the query and the update.
update toUpdate;
}
}
The race is small but real: between the query and the update, a record can be soft-deleted by another user. The map-based lookup catches some cases, but a record can also be re-deleted between the lookup and the DML call. Real-world race conditions are rare; the more common cause is stale ids from a cached upstream source that hasn't refreshed.
The fix: choose the right strategy for "deleted records"
Three options, each with different semantic.
Option 1: Skip soft-deleted records. The cleanest path when "deleted" is a real signal that the record shouldn't be touched. Filter the query to exclude soft-deleted (which is the default) and gracefully handle the missing ids in your code.
Option 2: Include soft-deleted with ALL ROWS. SOQL supports the ALL ROWS keyword, which returns both active and soft-deleted records. Use it when you genuinely need to know what happened to a record, including its deletion history.
List<Account> allAccounts = [
SELECT Id, Name, IsDeleted
FROM Account
WHERE CreatedDate = LAST_N_DAYS:30
ALL ROWS
];
ALL ROWS only works in SOQL, not in REST or SOAP API queries (those use different filtering options). It also doesn't help with DML; you can't update a record returned by ALL ROWS unless you undelete it first.
Option 3: Undelete first. If the right semantic is to restore the record and continue processing, use undelete:
List<Account> toRestore = [
SELECT Id FROM Account
WHERE Id IN :targetIds
AND IsDeleted = TRUE
ALL ROWS
];
if (!toRestore.isEmpty()) {
undelete toRestore;
}
// Now safe to query and update normally.
undelete restores soft-deleted records to active state, including their original audit fields. It does not work on hard-deleted records (those are gone for good).
The fixed example
The snapshot job rewritten to handle soft-deleted accounts gracefully:
public class AccountSnapshotJob implements Database.Batchable<sObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Account__c FROM Account_Snapshot__c WHERE Snapshot_Date__c = TODAY'
);
}
public void execute(Database.BatchableContext bc, List<Account_Snapshot__c> snapshots) {
Set<Id> accountIds = new Set<Id>();
for (Account_Snapshot__c s : snapshots) {
if (s.Account__c != null) accountIds.add(s.Account__c);
}
if (accountIds.isEmpty()) return;
// Query active records only. Soft-deleted accounts won't appear.
Map<Id, Account> liveById = new Map<Id, Account>(
[SELECT Id, AnnualRevenue, Snapshot_Date__c
FROM Account
WHERE Id IN :accountIds]
);
List<Account> toUpdate = new List<Account>();
List<Id> missingIds = new List<Id>();
for (Id aid : accountIds) {
Account a = liveById.get(aid);
if (a == null) {
missingIds.add(aid);
continue;
}
a.Snapshot_Date__c = Date.today();
toUpdate.add(a);
}
if (!missingIds.isEmpty()) {
// Log the deleted/missing ids for later cleanup.
System.debug(LoggingLevel.INFO,
'Skipped ' + missingIds.size() + ' deleted accounts: ' + missingIds);
}
if (!toUpdate.isEmpty()) {
// Use partial-success so one bad record doesn't poison the batch.
Database.update(toUpdate, false);
}
}
}
Changes from the broken version: the missing-id case is logged, the partial-success variant of update insulates the batch from individual failures, and the empty-list guard prevents the trivial no-op DML call.
When ALL ROWS is the right answer
ALL ROWS is useful for:
- Audit and reporting queries where you need to know who was deleted and when.
- Recovery tooling that builds a list of deletable ids and tries to undelete in bulk.
- Compliance reports where regulatory rules require knowing the history of every record.
Don't use ALL ROWS as a workaround for "I want to update deleted records." That's a semantic error: deleted records should be undeleted first (if you want them back) or left alone (if they should stay deleted). The ALL ROWS keyword gives you visibility, not write access.
The 15-day window
Soft-deleted records sit in the recycle bin for 15 days by default. After that, the platform's background process purges them, and they become hard-deleted. Once hard-deleted:
- The id is no longer valid (a fresh query by id returns nothing).
- Undelete fails: there's nothing to restore.
- Foreign-key references to that id from other records become orphans (the platform usually cleans these on the purge, but lookups to non-existent ids can cause downstream errors).
If you need a record back that was hard-deleted, the only recovery path is the Data Recovery Service (a paid Salesforce service that restores from backups). It's expensive and slow. Avoid the situation by being explicit about deletion semantics.
For records that should never be hard-deleted, increase the recycle-bin retention via Setup → Data Management → Data Loader (if it offers retention overrides) or by configuring scheduled purge jobs to skip certain object types.
The interaction with cascade deletion
Deleting a parent record cascades to its children on most master-detail relationships. The cascade is a soft delete on each child. If your code holds child record ids and tries to update them after the parent was deleted, you get ENTITY_IS_DELETED on the children.
The pattern of failure is misleading: your code didn't delete the children, the platform did, as a side effect of someone else deleting the parent. The diagnostic is to inspect the parent's deletion state and the cascade behavior of the relationship.
Account a = [SELECT IsDeleted FROM Account WHERE Id = :accountId ALL ROWS];
if (a.IsDeleted) {
// The parent was deleted; cascade soft-deleted the children.
}
For relationships that should not cascade (lookup vs master-detail), the child records survive parent deletion. For master-detail with cascade, they don't. The relationship type determines the behavior, so verify it in Setup → Object Manager → the parent object → Fields & Relationships.
A subtle race condition
If two users (or two transactions) both target the same record at the same time, and one deletes while the other updates, the update can fail mid-transaction. The error message names ENTITY_IS_DELETED. The transaction rolls back; the data is unchanged; the user sees an unexplained save failure.
The fix is rarely on your side directly; you can retry the operation after a short delay. Some applications wrap critical updates in a retry loop:
Integer attempts = 0;
while (attempts < 3) {
try {
update record;
break;
} catch (DmlException ex) {
if (ex.getDmlType(0) == StatusCode.ENTITY_IS_DELETED) {
attempts++;
// Brief pause; in real code use Queueable with a delay.
} else {
throw ex;
}
}
}
The retry-with-backoff pattern is fragile (Apex doesn't have a native sleep), so it's typically structured as a Queueable chain rather than an inline loop. Use it sparingly.
Defensive patterns for integrations
When external systems hold Salesforce record ids, they can hold ids that have since been deleted. Three patterns reduce the friction:
Soft-validate before update. Before pushing an update from an external system, do a quick id-existence query: SELECT Id FROM Account WHERE Id IN :externalIds. The returned set tells you which ids are still alive. Filter your update payload to only include those ids.
Send the full record, not just the id. Some external systems retain the full record snapshot. If a Salesforce id is deleted, the external system can recreate the record from the snapshot via insert (with a new id) rather than update. The semantic decision is between "synchronize" (insert if missing, update if present) and "track" (update only; missing means already gone).
Use External_Id__c instead of native Id. If the external system has its own unique identifier for each record, store that in a custom External_Id__c field on the Salesforce object with the unique flag set. Upserts via the external id are robust to Salesforce deletions: if the record was deleted, the upsert recreates it; if it exists, it updates. The external id is the stable handle that survives Salesforce-side deletion.
Hard-delete via Database.emptyRecycleBin
Apex has a method to programmatically purge soft-deleted records: Database.emptyRecycleBin(ids). Calling it on a list of soft-deleted ids removes them from the recycle bin permanently. Most code shouldn't use this (the platform's natural 15-day purge is sufficient), but it's useful for:
- Data-privacy compliance flows that must remove personal data on request.
- Test setup that purges sandbox data before a fresh seed.
- Migration scripts that need a clean slate.
After emptyRecycleBin, the ids are hard-deleted. Any code holding those ids gets "record not found" rather than ENTITY_IS_DELETED on the next access.
A note on what users see
The ENTITY_IS_DELETED error usually surfaces in server-side Apex logs and isn't directly user-visible. The user sees a generic error message in the UI. If your application surfaces the platform's error to users, translate it to something useful: "The record you tried to update has been deleted. Please refresh and try again."
A clear translation saves the user a confused call to support.
Triggers and the deleted state
A trigger that runs before delete or after delete operates on records being deleted. Inside the trigger, Trigger.old holds the records as they were before deletion. Trigger.new is null for delete triggers (there's no "new" version of a deleted record).
If your trigger does a SOQL inside the delete handler and the query returns the same record, the result depends on whether the platform has committed the soft delete yet. In before delete, the record is still active. In after delete, the record is soft-deleted. Trying to update the record from after delete produces ENTITY_IS_DELETED.
For derived bookkeeping (e.g., decrementing a counter on a parent record when a child is deleted), the right place to do the work is after delete, and the work happens on a different record (the parent), not the deleted one. Updating the parent is fine; updating the child is not.
Soft-delete in scratch orgs and sandboxes
Scratch orgs and sandboxes have the same recycle bin behavior as production. Deleted records sit for 15 days unless explicitly purged. For test setup, this means a deleted record from a previous test run can interfere with the next setup if the test relies on External_Id__c uniqueness.
The Database.emptyRecycleBin call solves this: after each test's teardown, purge the recycle bin to remove any soft-deleted residue. Tests start with a clean state every time.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Validation errors
DUPLICATE_VALUE: duplicate value found
ValidationYou tried to insert or update a record with a value on a unique field (External ID set to "Unique" or one of the unique standard fields like…
ENTITY_IS_LOCKED: entity is locked for approval
ValidationThe record is currently in an Approval Process and is locked for editing until the approval is granted, rejected, or recalled. The lock is e…
FIELD_CUSTOM_VALIDATION_EXCEPTION: <your validation rule's error message>
ValidationA validation rule on the object evaluated to TRUE for the record being saved, blocking the save. The text after the colon is the rule's own …
INACTIVE_OWNER_OR_USER: operation performed with inactive User
ValidationYou set a record's Owner (or another User reference) to a deactivated user. Salesforce blocks new ownership pointing at inactive users by de…
INVALID_FIELD_FOR_INSERT_UPDATE
ValidationYou tried to set a field that the platform doesn't let you set in this context — usually a formula field, a system-managed field (CreatedDat…