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
A sales operations engineer runs a Data Loader job that updates 50,000 Opportunities with new pricing book versions. The job starts cleanly. Around 12,000 records in, the error log fills with UNABLE_TO_LOCK_ROW: unable to obtain exclusive access to this record or 1 records. The engineer pauses the job and tries again ten minutes later. Same error. The Opportunities involved are not the same ones; the failures spread across the data set.
What the platform is checking
Salesforce databases use pessimistic row-level locking. When a transaction touches a record (insert, update, upsert, delete, or even certain reads in specific contexts), the database places a lock on that row. The lock prevents other transactions from modifying the same row simultaneously. When the transaction commits, the lock releases.
If a second transaction tries to lock a row that is already locked by a first transaction, the second transaction waits a short time (typically 10 seconds) for the lock to clear. If the lock does not clear within the wait window, the second transaction fails with UNABLE_TO_LOCK_ROW. The platform refuses to wait indefinitely because that would create deadlocks and starve concurrent work.
The error is contention-driven. It only happens when multiple transactions are trying to modify the same data. A single transaction running alone never sees this error. Two transactions running in parallel against disjoint data also do not see it. The collision happens when both transactions touch a shared record or parent record at the same time.
Lock scope is wider than the literal record being modified. Updating a child record locks the parent. Updating an Opportunity locks the related Account. Updating an Account locks the related Account owner User. These cascading locks are how Salesforce preserves the integrity of master-detail and lookup relationships, but they also expand the contention surface significantly.
What is contending
Three common patterns produce most lock-row errors.
Parallel batches against the same parent. Two batch jobs that both update Opportunities related to the same Account contend on the Account row. Even though they are updating different Opportunities, each Opportunity update places a lock on the parent Account. Two parallel jobs collide.
Trigger cascades hitting a shared record. A trigger on Opportunity that updates the Account roll-up summary causes every Opportunity update to lock the parent Account. Two simultaneous Opportunity updates on different Opportunities under the same Account contend on the Account lock.
Manual updates during automation. A user edits a record in the UI while an integration is updating the same record. The UI save and the integration save collide on the row lock.
The broken example
A Data Loader job that updates Opportunities in parallel with concurrency set to 4 threads:
Id,Pricebook2Id
006xx000000001A,01sxx000000001A
006xx000000002A,01sxx000000001A
006xx000000003A,01sxx000000001A
006xx000000004A,01sxx000000001A
...
Each row updates a different Opportunity, but all 50,000 Opportunities belong to a relatively small set of Accounts. Many rows share parent Accounts. With 4 threads running concurrently, two threads frequently try to lock the same parent Account at the same time. One thread succeeds; the other gets UNABLE_TO_LOCK_ROW.
A second shape: an Apex batch with a trigger that rolls up to Account:
trigger OpportunityRollupTrigger on Opportunity (after insert, after update) {
Set<Id> accountIds = new Set<Id>();
for (Opportunity o : Trigger.new) {
if (o.AccountId != null) accountIds.add(o.AccountId);
}
Map<Id, Decimal> sumsByAccount = new Map<Id, Decimal>();
for (AggregateResult ar : [
SELECT AccountId, SUM(Amount) total
FROM Opportunity
WHERE AccountId IN :accountIds AND IsClosed = false
GROUP BY AccountId
]) {
sumsByAccount.put((Id) ar.get('AccountId'), (Decimal) ar.get('total'));
}
List<Account> toUpdate = new List<Account>();
for (Id accId : accountIds) {
toUpdate.add(new Account(Id = accId, Open_Pipeline__c = sumsByAccount.get(accId) ?? 0));
}
update toUpdate;
}
When a batch job updates 200 Opportunities per execute and those Opportunities span 50 Accounts, the trigger locks those 50 Accounts. Another execute running in parallel against another 200 Opportunities may hit the same Accounts. The two executes contend.
The fix, three paths
Sort the data by parent record id before processing. Group records by their parent so each batch contains records belonging to the same parent. This serializes locks on each parent rather than spreading them across batches.
Id,Pricebook2Id,AccountId
006xx000000001A,01sxx,001xx000000001A
006xx000000002A,01sxx,001xx000000001A
006xx000000003A,01sxx,001xx000000001A
006xx000000004A,01sxx,001xx000000002A
006xx000000005A,01sxx,001xx000000002A
...
The CSV is sorted by AccountId. Data Loader's batch size of 200 typically puts all the children of a single Account in the same batch. Lock contention drops dramatically because no two parallel batches are touching the same Account.
Reduce concurrency. Lower the parallel-thread count in the integration tool. Data Loader's "Insert null values" and "Use Bulk API" settings interact with batch size. Reducing the parallel insert count from 4 to 1 eliminates contention at the cost of total throughput. For a one-time job, this is often acceptable.
Use FOR UPDATE in Apex. When the code itself needs to update a parent and several children atomically, SELECT ... FOR UPDATE locks the relevant rows at the start of the transaction. Other transactions that try to lock the same rows wait. The pattern serializes contention rather than failing.
public static void updateOpportunityWithAccountSummary(Id oppId, Decimal newAmount) {
Opportunity opp = [
SELECT Id, AccountId, Amount
FROM Opportunity
WHERE Id = :oppId
FOR UPDATE
];
Account a = [
SELECT Id, Open_Pipeline__c
FROM Account
WHERE Id = :opp.AccountId
FOR UPDATE
];
opp.Amount = newAmount;
update opp;
a.Open_Pipeline__c = (a.Open_Pipeline__c ?? 0) + (newAmount - (opp.Amount ?? 0));
update a;
}
The FOR UPDATE clause queues other transactions that try to read or write these rows. Each waits its turn. No transaction fails with the lock error; throughput is lower but the work completes deterministically.
The fixed example
A batch update pattern with sorted input, reduced concurrency, and a retry strategy:
public class OpportunityUpdateBatch implements Database.Batchable<SObject>, Database.Stateful {
private List<SaveError> errors = new List<SaveError>();
private static final Integer MAX_RETRIES = 3;
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, AccountId, Pricebook2Id FROM Opportunity ' +
'WHERE Needs_Pricebook_Update__c = true ' +
'ORDER BY AccountId'
);
}
public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
for (Opportunity o : scope) {
o.Pricebook2Id = '01sxx000000001A';
o.Needs_Pricebook_Update__c = false;
}
Integer attempts = 0;
Boolean success = false;
while (!success && attempts < MAX_RETRIES) {
attempts++;
Database.SaveResult[] results = Database.update(scope, false);
List<Opportunity> failures = new List<Opportunity>();
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
Database.Error err = results[i].getErrors()[0];
if (String.valueOf(err.getStatusCode()) == 'UNABLE_TO_LOCK_ROW') {
failures.add(scope[i]);
} else {
errors.add(new SaveError(scope[i].Id, err.getMessage()));
}
}
}
if (failures.isEmpty()) {
success = true;
} else {
scope = failures;
if (attempts < MAX_RETRIES) {
Long sleepEnd = System.currentTimeMillis() + 100 * attempts;
while (System.currentTimeMillis() < sleepEnd) { /* brief wait */ }
}
}
}
}
public void finish(Database.BatchableContext bc) {
if (!errors.isEmpty()) {
insert createErrorLogs();
}
}
private List<Pricebook_Update_Error__c> createErrorLogs() {
List<Pricebook_Update_Error__c> logs = new List<Pricebook_Update_Error__c>();
for (SaveError e : errors) {
logs.add(new Pricebook_Update_Error__c(Opportunity__c = e.recordId, Message__c = e.message));
}
return logs;
}
public class SaveError {
public Id recordId;
public String message;
public SaveError(Id rid, String msg) { this.recordId = rid; this.message = msg; }
}
}
The batch sorts by AccountId to group siblings together. Failed updates due to lock contention are retried with a short delay. Non-recoverable errors are captured to a custom log object.
Edge cases and gotchas
Master-detail relationships. Updating a master-detail child locks the parent. The lock is broader than for a lookup relationship. Two transactions updating different children of the same master collide on the master lock. Master-detail roll-up summaries amplify the effect.
Owner changes. Changing the OwnerId on a record locks the User record of both the old owner and the new owner. Two parallel jobs that both reassign Cases to the same support user can collide on the User lock.
Sharing recalculation. Updating an OwnerId or modifying sharing-related fields triggers sharing recalculation. The recalculation places additional locks on related sharing records. Operations that change owners en masse are particularly prone to lock contention.
Self-relationships. A trigger that updates parent Account records when an Account is updated produces locks on records of the same object as the trigger. Recursive lock escalation up an Account hierarchy can produce contention even within a single transaction.
Skinny tables and indexed lookups. Lock behavior is unchanged by skinny tables, but query performance is. A skinny table can speed up the parent lookup, which reduces the lock-holding window per transaction and indirectly reduces contention.
Platform Events and CDC. Publishing a Platform Event from a trigger does not lock the parent record. Change Data Capture (CDC) publishes asynchronously and also does not lock. For audit-style use cases where you would otherwise update a parent on every child change, CDC or Platform Events avoid the lock entirely.
Defensive habits
Sort data by parent id before bulk loads. The single most effective preventive measure for Data Loader and Bulk API jobs is to pre-sort the input CSV by AccountId, ParentId, or whatever key the trigger logic uses to roll up.
Reduce concurrency when contention surfaces. Bulk API jobs default to concurrent processing. For jobs that touch shared parents, serial mode is often faster overall because the retries on lock failures cost more than the lost parallelism.
Build retry logic into Apex updates. The UNABLE_TO_LOCK_ROW error is transient by definition. The lock holder will commit shortly; the next attempt will succeed. A short retry with backoff handles routine contention without operator involvement.
Avoid synchronous parent updates from triggers when async will do. If the rollup does not need to be immediately consistent, write it from a queueable or batch instead of a trigger. The async write happens after the original transaction commits, removing the lock cascade from the hot path.
Monitor contention. Setup, Async Apex Jobs, and the Database queries dashboards show failed batches. A trend of UNABLE_TO_LOCK_ROW failures over time is a signal that the data model or the workload pattern needs rethinking.
Test patterns
Testing for lock contention is tricky because tests run in a single transaction by default. The fix is to design the test around the failure shape rather than the contention itself.
@IsTest
static void retryHandlesTransientLockFailure() {
Account a = new Account(Name = 'Test');
insert a;
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 5; i++) {
opps.add(new Opportunity(
Name = 'Test ' + i,
AccountId = a.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 1000
));
}
insert opps;
Test.startTest();
Database.executeBatch(new OpportunityUpdateBatch(), 200);
Test.stopTest();
Integer updated = [SELECT COUNT() FROM Opportunity WHERE Needs_Pricebook_Update__c = false];
System.assertEquals(5, updated);
}
For integration tests that exercise actual contention, set up two parallel jobs in a sandbox and observe the failure rate. Adjust sorting and retry parameters until the failure rate is acceptable.
Diagnosing in production
When the error fires:
- Identify the records involved from the error log.
- Identify their parent records (Account, Master-Detail parent, Owner User).
- Look for other jobs or users updating the same parents at the same time.
- Check whether triggers, flows, or process builders write to those parents on every save.
- Apply the appropriate fix: sort by parent, reduce concurrency, retry, or restructure the trigger.
The Async Apex Jobs Setup page shows batch failures with their error messages. Filtering for UNABLE_TO_LOCK_ROW reveals the pattern across multiple jobs.
Why pessimistic locking exists
Salesforce uses pessimistic locks because the platform must preserve referential integrity across complex multi-tenant data. Optimistic concurrency, where conflicts are detected at commit time and one party retries, would force the application to retry far more often under load. Pessimistic locks fail loudly when contention exists, giving the caller a clear signal to back off or retry. The platform exchanges some throughput for predictability, and integrations that respect the trade-off run reliably for years.
Quick recovery checklist
- Sort the input data by parent id.
- Reduce parallel concurrency on the integration.
- Add retry logic for transient lock failures.
- Refactor trigger-based roll-ups to async if possible.
- Monitor for recurrence over the following week.
Lock-row errors are visible but the fixes are well-understood. Most production occurrences resolve permanently after applying the sorting and retry patterns above.
Further reading from Salesforce
- API Developer Guide: Record Locking Cheat Sheet
- Apex Developer Guide: FOR UPDATE Clause
- Architect: Record Locking Considerations
- Apex Developer Guide: Batch Apex
- Trailhead: Develop With Records and Relationships
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_DELETED: entity is deleted
ValidationYou 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 d…
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…