If you build on Salesforce long enough, you will hit a lock. This post explains how Salesforce Record Locking and Concurrency work, why you see UNABLE_TO_LOCK_ROW, and how to write code that behaves correctly under load.
Why Salesforce locks records
In a multi user system, two updates on the same record at the same time can corrupt data. To prevent that, Salesforce uses exclusive row locks:
- When a transaction modifies a record, it takes a lock and holds it until the transaction ends.
- Another transaction that tries to update the same record waits for about 10 seconds. If the lock is not released in time, Salesforce throws
UNABLE_TO_LOCK_ROW.
Reads operations are different. A concurrent read operation sees the last committed version, not the in progress changes. That is normal isolation, meaning uncommitted changes are never visible to other transactions.
Reads vs writes in plain language
- Writes (DML) take a lock. Other writers wait.
- Reads (plain SOQL) do not lock and do not wait. They return the last committed data, which can be stale relative to an in flight update.
- Reads with
FOR UPDATEask for the same exclusive lock that DML uses. If a lock is held, the query waits. When it acquires the lock, it reads the freshest committed data and keeps the lock for the rest of the transaction.
Also Read: Selective Queries and Indexes – Salesforce things you should know
Quick comparison
| Operation | Takes a lock | Waits if locked | Data you read |
| Plain SOQL | No | No | Last committed only |
| DML (insert, update, delete, upsert) | Yes | Yes | N/A |
SOQL FOR UPDATE | Yes | Yes | Freshest committed, then lock is held |
The classic race condition Salesforce Record Locking
Start with an Opportunity where Amount = 100.
- User A edits the Opportunity in the UI and saves
Amount = 150. The write has started but is not committed yet. A holds the lock. - User B triggers a process to add 30. B runs a plain SOQL query and reads
Amount = 100because A’s change is not committed. - B sets
100 + 30 = 130and tries to update. B waits for A’s lock.
When A commits, the row is150. B’s pending update still writes130, which is wrong. The expected result was180.
Fix: make B read using FOR UPDATE so B waits, then reads the fresh value, then writes.

The right way to read before you write
Use FOR UPDATE when your logic must read a value and then write a value based on it, especially for money or counters.
public with sharing class OpportunityService {
// Safely increment Amount by `delta` using FOR UPDATE
public static void incrementAmount(Id oppId, Decimal delta) {
// Simple retry for transient lock contention
Integer maxAttempts = 3;
for (Integer attempt = 1; attempt <= maxAttempts; attempt++) {
try {
Opportunity o = [
SELECT Id, Amount
FROM Opportunity
WHERE Id = :oppId
FOR UPDATE
];
Decimal current = (o.Amount == null) ? 0 : o.Amount;
o.Amount = current + delta;
update o; // holds the lock until commit
return; // success
} catch (DmlException e) {
// Lock timeout or deadlock. Retry a couple of times.
Boolean isLockIssue = e.getMessage().contains('UNABLE_TO_LOCK_ROW');
if (!isLockIssue || attempt == maxAttempts) {
throw e;
}
// Optional: requeue work asynchronously instead of hot looping
}
}
}
}What this does:
- The
FOR UPDATEquery waits if the record is locked. - When the query returns, you have the lock and a fresh value.
- Your subsequent DML keeps the lock until commit, preventing other writers from sneaking in.
Practical guidance for Salesforce Record Locking
- Keep transactions short. Do not do heavy work between query and DML. Avoid long loops, large callouts, and complex triggers while holding locks.
- Move DML to the end. Gather changes, then update once. This reduces lock time.
- Use
FOR UPDATEonly when needed. Do not sprinkle it everywhere. Use it for read then write patterns where correctness matters, like financial amounts or inventory. - Be mindful of data skew. Avoid relating more than 10,000 child records to a single parent in lookups. Skew increases the chance of contention.
- Handle lock errors. Catch
UNABLE_TO_LOCK_ROW. Either retry a few times or requeue the work with Queueable so it runs later. - Batch safely. In batch or flows that touch many related rows, group by parent to reduce cross record contention.
- Design idempotent operations. If a retry runs twice, it should not corrupt totals.
Debugging checklist for Salesforce Record Locking
- Do you read a value and then write based on that value? If yes, consider
FOR UPDATE. - Are you doing DML in multiple places in a single transaction? Consolidate.
- Are you touching hot rows such as the same parent, the same owner, or the same counter object from many jobs? Stagger or shard the updates.
- Are long running triggers, workflows, or flows holding locks? Trim the work or move it async.
TL;DR
Salesforce Record Locking and Concurrency are there to protect your data. Plain SOQL reads last committed data and does not wait. DML and FOR UPDATE take locks and wait. When you must read then write correctly under contention, query with FOR UPDATE, keep the transaction short, and update once. Handle UNABLE_TO_LOCK_ROW with small retries or by deferring work.
That is the stable pattern that keeps totals correct and errors rare.
