Two writers are contending for the same row — almost always a shared parent record. Sort your records by parent Id before DML so concurrent chunks don't interleave on the same parent, shrink the batch scope, stop running the job alongside the integration that touches the same hierarchy, and add a bounded retry with Database.update(records, false) for the collisions you can't design away.
What the lock actually is
Every Salesforce transaction that writes a record takes an exclusive row lock on it and holds that lock until the transaction commits or rolls back. A second transaction that needs the same row does not fail immediately — it waits. If the lock is still held after roughly ten seconds, the waiting transaction fails with UNABLE_TO_LOCK_ROW.
The part that surprises people is implicit parent locking. Writing a child record locks its parent, because the parent's roll-up summaries, sharing, and ownership all have to stay consistent:
- Master-detail: inserting or updating a detail record locks the master.
- Account: writing a Contact, Opportunity, or Case locks the Account behind it.
- Opportunity: writing an OpportunityLineItem locks the Opportunity.
- Sharing and group membership: changes to Group, GroupMember, or role hierarchy lock broad swathes of data while sharing recalculates.
So a job that loads 10,000 Contacts spread across 50 Accounts is not writing 10,000 independent rows. It is contending on 50 hot parents, and if two chunks run in parallel they will collide.
The causes, ranked
1. Two writers on the same parent hierarchy
The dominant cause in production. A nightly batch updates Opportunities while an integration user updates the same Accounts, or a trigger on Contact rolls a value up to Account while a data load writes Contacts under that same Account. Neither job is wrong on its own; they just overlap in time and in the hierarchy they touch.
The fix is scheduling and ordering, not code cleverness: move the jobs apart in time, or make one of them the single writer for that object.
2. Batch Apex with unsorted scope
Batch Apex splits the query result into chunks (200 records by default) and Salesforce may run chunks concurrently. If the records are in arbitrary order, the same Account appears in several chunks at once. Sorting the batch query by the parent lookup keeps each parent inside one chunk:
public class ContactRollupBatch implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
// ORDER BY the parent lookup: all children of one Account land in the
// same chunk, so two chunks never contend for the same parent row.
return Database.getQueryLocator(
'SELECT Id, AccountId, Amount__c FROM Contact WHERE AccountId != null ORDER BY AccountId'
);
}
public void execute(Database.BatchableContext bc, List<Contact> scope) {
// ... build parent updates ...
}
public void finish(Database.BatchableContext bc) {}
}
// Serialise further by shrinking the scope, or by chaining instead of
// letting chunks run concurrently:
Database.executeBatch(new ContactRollupBatch(), 50);3. Parallel Apex test execution on shared data
In the org, Apex tests run in parallel by default. Tests that update the same seeded records — or that both write to a shared custom setting, a shared Account, or the same User — deadlock against each other and surface as UNABLE_TO_LOCK_ROW in a test that passes fine on its own.
The correct fix is data isolation: every test creates the records it touches, inside its own @testSetup or its own method. Disabling parallel Apex testing in the org makes the symptom go away but leaves the real problem — tests that are not independent — in place.
4. A FOR UPDATE lock held across slow work
FOR UPDATE locks the queried rows for the rest of the transaction. That is the right tool for a read-modify-write, but every millisecond between the query and the commit is time another transaction spends waiting. Never put a callout, a long loop, or an unrelated batch of DML between the two:
// Bad: the lock is held while the callout runs
Account a = [SELECT Id, Balance__c FROM Account WHERE Id = :accountId FOR UPDATE];
HttpResponse res = new Http().send(buildRequest(a)); // seconds of lock time
a.Balance__c = parseBalance(res);
update a;
// Better: do the slow work first, lock last, commit immediately
HttpResponse res = new Http().send(buildRequest(accountId));
Decimal balance = parseBalance(res);
Account a = [SELECT Id, Balance__c FROM Account WHERE Id = :accountId FOR UPDATE];
a.Balance__c = balance;
update a;A callout after DML in the same transaction is its own error (CalloutException: You have uncommitted work pending) — the point here is simply that lock duration is the variable you control.
5. Group, sharing, and ownership recalculation
Changing role hierarchy, group membership, or record ownership triggers sharing recalculation, which locks a large number of rows for a long time. Data jobs that run during a recalculation window fail with this error and nothing in the job's own code is at fault. Schedule ownership and role changes outside your integration windows.
Handling the collisions you can't design away
Contention you cannot eliminate should be absorbed, not propagated. Switch to partial success, classify the failures, and retry only the lock ones with a bounded number of attempts:
public class LockAwareDml {
public static final String LOCK_CODE = 'UNABLE_TO_LOCK_ROW';
// A save error is worth retrying only when it is a lock timeout.
// Validation and permission failures will fail again identically.
public static Boolean isRetryable(Database.SaveResult sr) {
if (sr.isSuccess()) return false;
for (Database.Error e : sr.getErrors()) {
if (e.getStatusCode() == StatusCode.UNABLE_TO_LOCK_ROW) return true;
}
return false;
}
// Returns the records that still failed after maxAttempts.
public static List<SObject> updateWithRetry(List<SObject> records, Integer maxAttempts) {
List<SObject> pending = records.clone();
for (Integer attempt = 0; attempt < maxAttempts && !pending.isEmpty(); attempt++) {
List<Database.SaveResult> results = Database.update(pending, false);
List<SObject> retryable = new List<SObject>();
for (Integer i = 0; i < results.size(); i++) {
if (isRetryable(results[i])) {
retryable.add(pending[i]);
}
}
pending = retryable;
}
return pending;
}
}Two rules for the retry: keep the attempt count small (three is plenty — if the lock is still held after three passes, the contention is structural), and never retry inside a trigger. A trigger runs inside the save pipeline that is already holding locks; retrying there makes the contention worse. Requeue the work instead.
Reading the message
System.DmlException: Update failed. First exception on row 0
with id 001Ax000001pAbCIAU;
first error: UNABLE_TO_LOCK_ROW,
unable to obtain exclusive access to this record or 200 records: []- The Id after "with id" is the contended record. Check its key prefix — if it is
001(Account) while you were updating Contacts, you have found your implicit parent lock. - "or 200 records" means the lock attempt covered a whole chunk, not one row. That points at batch scope or a bulk DML statement.
- The row index is the position in the list you passed to DML, which lets you map back to the source record.
What you can — and can't — test locally
Be clear about the boundary here. Nimbus runs Apex locally against an embedded PostgreSQL; it does not model Salesforce's cross-transaction row-lock arbitration, and it accepts FOR UPDATE without acquiring a platform-equivalent lock. A local run will not raise UNABLE_TO_LOCK_ROW, and no local tool can honestly claim to reproduce org lock contention.
What is worth testing locally is everything you wrote around the lock — which is where the bugs actually are. The retry helper above, the classification of which errors deserve a second attempt, the sort-before-DML ordering, and your partial-success handling of Database.SaveResult are all ordinary Apex, and they run in milliseconds with no deploy:
# Exercise the retry / classification logic
nimbus test "LockAwareDmlTest.*"
# Or check the ordering of a batch's scope directly
nimbus exec scripts/check-batch-ordering.apex