Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Validation

UNABLE_TO_LOCK_ROW: unable to obtain exclusive access to this record or 1 records

Two transactions tried to update the same record at the same time. Salesforce takes row-level locks during DML; one transaction holds the lock, the other waits, then times out and throws this error. Often a sign of contention on a "hot" parent record.

Also seen asUNABLE_TO_LOCK_ROW·unable to obtain exclusive access·UNABLE_TO_LOCK_ROW: unable to obtain

Salesforce's database locks rows during update. If your transaction tries to lock a row that another transaction has already locked, you wait. If you wait more than ~10 seconds, the platform gives up and throws this error.

The hot-parent pattern

The most common cause: many DMLs touching the same parent record indirectly.

// Account "ACME" is referenced by 5,000 Contacts.
// Updating any Contact can take a "share lock" on the parent Account.
// 100 Contact updates in parallel = 100 transactions trying to lock Account.

for (Contact c : contacts) {
    c.Account.Last_Touched__c = System.now();   // updates parent → locks ACME
    update c.Account;
}

The classic shape: a custom field on the parent that summarises children, and many children updating the parent simultaneously. The parent's lock becomes the bottleneck.

Fix 1: bulk-update the parent once

Instead of N updates triggering N parent locks, aggregate the update:

Map<Id, Datetime> latestByAccount = new Map<Id, Datetime>();
for (Contact c : contacts) {
    latestByAccount.put(c.AccountId, System.now());
}
List<Account> parents = new List<Account>();
for (Id aid : latestByAccount.keySet()) {
    parents.add(new Account(Id = aid, Last_Touched__c = latestByAccount.get(aid)));
}
update parents;   // one DML, locks each parent once

Fix 2: sequential processing for hot rows

If concurrent processing is unavoidable, serialize access to the hot parent. Pattern: a queueable per parent, processed one at a time.

public class HotParentProcessor implements Queueable {
    public Id parentId;
    public List<Id> childIds;
    public HotParentProcessor(Id pid, List<Id> kids) {
        this.parentId = pid; this.childIds = kids;
    }
    public void execute(QueueableContext ctx) {
        // process one parent's children atomically
    }
}

Each queueable holds the parent's lock for one transaction's duration; the next queueable picks up after.

Fix 3: explicit FOR UPDATE

If you need to read-then-update under a lock you control:

List<Account> hot = [SELECT Id, Counter__c FROM Account WHERE Id = :parentId FOR UPDATE];
hot[0].Counter__c += 1;
update hot;

FOR UPDATE takes the lock at SELECT time and holds it until the transaction commits. Other transactions see the lock and queue. This is heavyweight — use it only for genuinely critical contention.

A common false fix: longer timeouts

You can't extend the 10-second lock-wait. The platform's timeout is fixed. Trying to "retry until it works" usually just shifts the contention without resolving it.

Diagnose under load

Tail the debug log filtered to DML_BEGIN / DML_END events. The transactions that always fail will be the ones touching the same rows. Sometimes the answer is design-level: a master-detail that should be a lookup, a roll-up summary that should be denormalised, etc.

A subtler source: triggers updating shared parents

Two unrelated triggers (Contact's and Opportunity's) both updating an Account roll-up. They run in different transactions but both want the Account's lock. Either:

  • Move the parent update to async (queueable) so they serialize naturally
  • Use Skinny Tables or deferred sharing recalculation to reduce lock contention on hot rows

Related dictionary terms