SfSkills Domains Docs GitHub

Home / apex / record-locking-and-contention

record-locking-and-contention

Use when diagnosing or preventing UNABLE_TO_LOCK_ROW errors, record lock contention in Apex or Bulk API loads, FOR UPDATE locking, parent-child lock escalation, and deadlock scenarios. Triggers: 'UNABLE_TO_LOCK_ROW', 'record lock contention', 'FOR UPDATE SOQL', 'deadlock', 'lock timeout'. NOT for locks from a Flow — use flow/flow-record-locking-and-contention. NOT for approval-process locking — use admin/approval-processes.

Open the skill folder on GitHub


Record Locking and Contention

Use this skill when transactions fail with UNABLE_TO_LOCK_ROW or when high-concurrency workloads contend on the same rows. The objective is to identify the locking mechanism involved, eliminate unnecessary contention, and add resilient retry logic where contention is unavoidable.


Before Starting

Gather this context before working on anything in this domain:


Core Concepts

Exclusive Row Locks and the 10-Second Timeout

Every DML statement in Salesforce acquires an exclusive row lock on the affected records for the duration of the transaction. If a second transaction attempts to lock the same row, it waits up to 10 seconds. After that timeout, the platform throws UNABLE_TO_LOCK_ROW and the waiting transaction fails. This is not configurable.

The lock blocks writes, not reads. While a row is locked, no other client or user is allowed to update it — through code or the Salesforce UI — but other transactions can still query it. Frame contention as "concurrent writers waiting," not "the record is unavailable": readers pass through, and the lock's guarantee is only that the locked records won't be changed by another client during the lock period.

FOR UPDATE Acquires Explicit Locks in SOQL

Adding FOR UPDATE to a SOQL query acquires exclusive locks on the returned rows immediately, before any DML occurs — the platform's own framing is that you use it "to lock sObject records while they're being updated in order to prevent race conditions and other thread safety problems." This is useful for read-modify-write patterns where you need to guarantee no other transaction changes the row between your read and your write. Three behaviors are easy to miss:

Parent-Child Lock Escalation

When you insert, update, or delete a child record, Salesforce also locks the parent record to maintain rollup summaries, sharing calculations, and relationship integrity. This means concurrent child-record operations against the same parent contend on the parent row even though no two transactions touch the same child. This is the primary cause of lock contention in orgs with data skew.

Deadlocks from Inconsistent Lock Ordering

When two transactions lock the same set of rows in different orders, a deadlock can occur. Transaction A holds Row 1 and waits for Row 2; Transaction B holds Row 2 and waits for Row 1. The platform detects this and fails one transaction with UNABLE_TO_LOCK_ROW. Consistent lock ordering by a deterministic key (such as record ID) prevents this.


Common Patterns

Sort-Before-DML to Prevent Deadlocks

When to use: Any batch or bulk operation that processes records from multiple parent groups or touches rows that other concurrent transactions may also touch.

How it works: Sort the records by a deterministic key (parent ID, or record ID) before performing DML. This guarantees all concurrent transactions acquire locks in the same order, eliminating deadlock risk.

// Sort by AccountId before update to prevent deadlock
List<Contact> contacts = [SELECT Id, AccountId, LastName FROM Contact WHERE ...];
contacts.sort(); // Custom comparator on AccountId
update contacts;

Why not the alternative: Processing records in arbitrary order is the root cause of most deadlocks in parallel batch operations.

Retry via Queueable for Transient Lock Failures

When to use: When lock contention is occasional and unavoidable, such as high-volume integrations writing to shared parent records.

How it works: Catch UNABLE_TO_LOCK_ROW in a try-catch block and enqueue a Queueable to retry the failed subset of records after a short delay.

public class RetryableUpdate implements Queueable {
    private List<SObject> records;
    private Integer retryCount;

    public RetryableUpdate(List<SObject> records, Integer retryCount) {
        this.records = records;
        this.retryCount = retryCount;
    }

    public void execute(QueueableContext ctx) {
        try {
            update records;
        } catch (DmlException e) {
            if (e.getMessage().contains('UNABLE_TO_LOCK_ROW') && retryCount < 3) {
                System.enqueueJob(new RetryableUpdate(records, retryCount + 1));
            } else {
                throw e; // Exhausted retries or different error
            }
        }
    }
}

Why not the alternative: Retrying synchronously in a loop wastes governor limits and still contends on the same lock window. Queueable retry introduces a natural delay.

Caveat: This pattern catches DmlException, which covers a blocked DML write. If the contended access is a FOR UPDATE query that can't acquire its lock, the failure arrives as a QueryException instead — add a matching catch (QueryException e) branch or the retry silently won't fire.

Bulk API Serial Mode for Skewed Data

When to use: Bulk API loads that target child objects with high parent-record skew (such as loading Contacts where many share the same Account).

How it works: Sort the CSV by parent ID and set the Bulk API job to serial mode. Serial mode processes one batch at a time, so no two batches contend on the same parent row simultaneously.

Why not the alternative: Parallel mode is faster but creates multiple batches that can lock the same parent row concurrently, causing widespread UNABLE_TO_LOCK_ROW failures.


Decision Guidance

SituationRecommended ApproachReason
Bulk API loads failing on child objects with shared parentsSort CSV by parent ID and use serial modeEliminates parallel contention on parent rows
Apex batch job with intermittent lock errorsSort scope records by parent key before DMLPrevents deadlock from inconsistent ordering
High-frequency integrations writing to shared recordsCatch UNABLE_TO_LOCK_ROW and retry via QueueableTransient contention resolves with a short delay
Extreme data skew (10,000+ children per parent)Request Granular Locking from Salesforce SupportPrevents child DML from escalating to parent lock
Read-modify-write requiring consistencyUse FOR UPDATE but minimize post-query logicReduces the lock-hold window
Multiple objects updated in one transactionLock rows in consistent ID order across all transactionsPrevents deadlocks

Step-by-step instructions for an AI agent or practitioner working on this task:

  1. Identify the contention source — examine error logs for UNABLE_TO_LOCK_ROW, note which object and operation triggers it, and determine whether the cause is parallel batches, parent-child escalation, or concurrent user actions.
  2. Map the data model — identify parent-child relationships involved and check for data skew (a single parent with thousands of children).
  3. Check lock ordering — review Apex code and batch logic to confirm records are processed in a deterministic, consistent order across all concurrent transactions.
  4. Apply the appropriate pattern — sort-before-DML for deadlocks, serial mode for Bulk API skew, Queueable retry for transient failures, or Granular Locking for extreme skew.
  5. Validate — run the checker script against the codebase, load-test with concurrent transactions, and confirm UNABLE_TO_LOCK_ROW errors no longer appear in the target scenario.

Review Checklist

Run through these before marking work in this area complete:


Salesforce-Specific Gotchas

Non-obvious platform behaviors that cause real production problems:

  1. Child DML locks the parent row — inserting or updating a child record also acquires an exclusive lock on the parent. This is invisible in code but is the leading cause of contention in skewed data models.
  2. FOR UPDATE locks persist for the entire transaction — if you run expensive logic after a FOR UPDATE query, you hold locks far longer than necessary, creating a wide contention window for other transactions.
  3. The 10-second lock timeout is not configurable — you cannot extend or shorten it. If your transaction holds a lock for more than 10 seconds, any concurrent transaction waiting on the same row will fail.
  4. Aggregate SOQL on locked rows also blocks — a SOQL query with GROUP BY or aggregate functions on rows that another transaction has locked will wait and potentially time out, even though no DML is involved.
  5. A stalled FOR UPDATE query throws QueryException, not DmlException — the 10-second lock wait applies to FOR UPDATE queries too, but when it expires the query raises a QueryException. Retry and error-handling code written around DmlException won't see it.
  6. A callout releases every FOR UPDATE lock — issuing a callout mid-transaction automatically drops the locks acquired by FOR UPDATE, even though the transaction is still open. Reads-modify-writes that span a callout lose their concurrency guarantee.

Output Artifacts

ArtifactDescription
Lock contention root cause analysisIdentifies the locking mechanism, contention source, and data-skew factors
Lock ordering recommendationDeterministic sort strategy for DML operations to prevent deadlocks
Retry strategyQueueable-based retry pattern for transient UNABLE_TO_LOCK_ROW errors
Bulk API configuration guidanceSerial-mode and CSV-sorting recommendations for skewed data loads

Package files