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
