Skip to content

UNABLE_TO_LOCK_ROW: The Error That Is Really About Your Data

Row lock errors surface in code but are usually caused by data shape. Why retrying makes it worse, and how skew turns a working job into a failing one.

TL;DR

  • UNABLE_TO_LOCK_ROW means another transaction was holding a lock on a record yours needed, and yours gave up waiting. It is contention, not corruption, and nothing is wrong with the record.
  • It surfaces in Apex, a data load or an integration, but the cause is usually the shape of your data, which is why it appears in code that worked for two years.
  • Master-detail locks the parent whenever a child is inserted, updated or deleted. Put 50,000 children under one parent and every write to any of them queues behind the same lock.
  • The same applies to ownership skew, where one user owns a very large number of records in a private sharing model, because sharing recalculation has to touch them.
  • Retrying is the instinct and it is wrong. More attempts against a contended row means more contention. It converts a fast failure into a slow one.
  • What works: smaller batches, sorting records so concurrent jobs take locks in the same order, serialising jobs that fight, and fixing the skew underneath.

What You'll Learn

  • What actually holds a lock, and for how long
  • Why master-detail relationships make this worse than lookups
  • The three shapes of skew, and which one you probably have
  • Why sorting records before DML prevents a whole class of failure
  • Which mitigations treat the symptom and which treat the cause

The Problem

The job ran nightly for two years. Nobody changed it. This week it started failing with UNABLE_TO_LOCK_ROW, intermittently, and only sometimes on the same records.

That pattern is the signature of a contention problem, and it is why this error is so frustrating to debug. There is no defect to find in the code that reported it. The code is fine. What changed is that the data underneath it crossed a threshold, or a second process started running at the same time, and now two transactions want the same row.

Salesforce locks records during writes to protect integrity. A transaction that cannot acquire a lock waits briefly and then fails rather than waiting forever. So the error is really a report about concurrency, delivered to whichever transaction lost.

Common questions this article answers:

  • Why does this appear in code that has not changed?
  • Why does updating a child record lock something else entirely?
  • Why do my retries make the problem worse rather than better?

Quick Answer

UNABLE_TO_LOCK_ROW means your transaction could not acquire a lock on a record because another transaction held it, and rather than waiting indefinitely your transaction failed. In a master-detail relationship the parent record is locked whenever a child is inserted, updated or deleted, so concurrent writes to children of the same parent serialise on that parent and eventually time out. The same contention arises from data skew, where more than roughly 10,000 children hang off one parent, from ownership skew, where one user owns a very large number of records in a private sharing model and sharing recalculation must touch them all, and from lookup skew, where many records point at the same record through a lookup. Retrying increases contention rather than resolving it. The effective mitigations are reducing batch size below the default 200 so fewer records compete, sorting records by parent id before DML so concurrent jobs acquire locks in a consistent order rather than deadlocking, avoiding parallel jobs that write the same parents, and redistributing the data so no single parent or owner is a bottleneck.

What holds a lock, and for how long

The mechanics are simple and the consequences are not.

A lock is held for the duration of the transaction, not the statement. If your transaction updates a child record, does a callout, runs some logic and then commits, the parent lock is held across all of it. Long transactions hold locks longer, and locks held longer collide more.

What takes a lock you might not expect:

  • Inserting, updating or deleting a child in a master-detail relationship locks the parent
  • Changing a record's owner can trigger sharing recalculation across related records
  • Updating a record that participates in a roll-up summary locks the record being rolled up to
  • Reparenting a child locks both the old and new parent

That third one is worth dwelling on. A roll-up summary is a convenience feature that quietly makes every child write a parent write, which means a rollup on a heavily-childed parent is a contention machine.

The three shapes of skew

Skew is the underlying condition. Three variants, each producing the same symptom.

Data skew, or child skew. Too many child records under one parent. The commonly cited guideline is to keep it under roughly 10,000 children per parent. The classic instance is a catch-all Account, named something like "Unknown" or "Individual", that accumulated 200,000 contacts because it was the default in an import. Every write to any of those contacts contends on that one account.

Ownership skew. One user owns a very large number of records, typically an integration user or a departed employee whose records were reassigned wholesale. In a private sharing model this is expensive, because changing anything that affects visibility forces recalculation across everything they own. Ownership skew is the one that most often surprises people, because the data model looks fine and the problem is in a field nobody thinks of as structural.

Lookup skew. Many records point at the same record through a lookup rather than master-detail. Less severe than master-detail, since there is no automatic parent lock, but at high concurrency the same record still becomes a hot spot.

Finding your skew is a query rather than an investigation:

-- Accounts with the most contacts, the usual suspect
SELECT AccountId, COUNT(Id) children
FROM Contact
GROUP BY AccountId
ORDER BY COUNT(Id) DESC
LIMIT 10
-- Ownership concentration
SELECT OwnerId, COUNT(Id) owned
FROM Account
GROUP BY OwnerId
ORDER BY COUNT(Id) DESC
LIMIT 10

If the top row of either is orders of magnitude above the second, you have found it.

Why retrying makes it worse

The instinct when a transaction fails on a lock is to try again. Most integration frameworks do it by default.

Consider what that does. Two jobs want the same parent. One wins, one fails and immediately retries. Now the retry is competing with whatever else has arrived in the meantime, and it is holding its own locks while it waits. At any real volume, retries pile up, each holding partial locks, each extending the window in which others fail.

Retry converts a fast, obvious failure into a slow, intermittent one that is much harder to diagnose. If you must retry, do it with exponential backoff and a jitter, so attempts spread out rather than synchronising, and cap the attempts. But treat retry as damage limitation, not a fix.

What actually works

Roughly in order of how quickly you can apply them.

Reduce batch size. The default is 200. Halving it means fewer records per transaction, shorter transactions, and less chance two batches touch the same parent. This is the fastest mitigation and often enough on its own for a load that fails occasionally.

Sort records before DML. This one is underused and prevents a whole class of failure. When two concurrent jobs update overlapping sets of children in different orders, each can hold a lock the other needs, which is a deadlock rather than simple contention. If every job sorts its records by parent id before writing, all jobs acquire locks in the same order, so one waits for the other instead of both failing. It is a small change with a disproportionate effect on jobs that fail unpredictably.

Stop running things in parallel that touch the same parents. Parallel Batch Apex, or an integration running while a scheduled job runs, is a common cause of a job that only fails at 2am. Serialising them costs wall-clock time and removes the contention entirely.

Shorten transactions. Move callouts out of the transaction that holds the lock. A synchronous callout inside a DML transaction holds a parent lock for the duration of a network round trip, which is an eternity in lock terms.

Fix the skew. The real fix, and the slowest. Distribute children across parents rather than a catch-all, reassign ownership away from a single user, and reconsider master-detail where a lookup would do. Reducing children per parent is the only change that removes the ceiling rather than raising it.

Worth noting that skew is a data architecture problem that presents as an operations problem, which is why it usually gets treated repeatedly rather than solved. If your org has a catch-all parent, it will keep producing these errors under new names until it is dealt with.

Frequently Asked Questions

Q: Why did this start when nothing changed?

A: Because the data changed even if the code did not. A parent crossed a child-count threshold, an integration started running concurrently with a scheduled job, or record volume grew until transactions overlapped. This error is a function of concurrency and data shape, both of which drift without anybody deploying anything.

Q: Does master-detail really lock the parent on every child write?

A: Yes. Inserting, updating or deleting a child in a master-detail relationship locks the parent for the duration of the transaction. That is the design, because the parent's roll-ups and sharing depend on its children. It is also why master-detail plus a high child count is the most reliable way to produce this error.

Q: Will smaller batches fix it permanently?

A: They reduce the probability of collision, they do not remove the cause. If you have 200,000 contacts on one account, smaller batches make the failure rarer without making it impossible, and volume growth will eventually undo the gain. Treat it as a mitigation that buys time to address the skew.

Q: How is this different from a row lock in a normal database?

A: Conceptually it is the same contention, with two Salesforce-specific twists. Locks are held for the whole transaction rather than the statement, and writing a child implicitly locks a parent you did not name, so the contended record is often not one your code mentions.

Q: What is a safe number of children per parent?

A: The commonly used guideline is under 10,000, and it is a guideline rather than a hard limit. Concurrency matters as much as count: 5,000 children being written constantly is worse than 50,000 that are read and rarely updated. Use it as a signal to investigate, not a threshold to defend.

Key Takeaways

  • It is contention, not corruption. Another transaction held the lock and yours stopped waiting.
  • The cause is data shape, not the code that reported it, which is why it appears in jobs nobody changed.
  • Master-detail locks the parent on every child write, so many children under one parent serialises them all.
  • Retrying increases contention. Use backoff and jitter if you must, but it is damage limitation.
  • Sorting records by parent id before DML makes concurrent jobs take locks in the same order and removes deadlocks.
  • Only fixing the skew removes the ceiling. Everything else raises it.

What's Next?

Recommended Reading:

Action Items:

  1. Run the two grouping queries above to find your worst parent and your most concentrated owner. If the top row dwarfs the second, that is your cause.
  2. Sort records by parent id before DML in every batch and integration that writes children, which costs nothing and prevents deadlock ordering.
  3. Check whether any two jobs writing the same parents run concurrently, and stagger them before touching anything else.

Resources & References

Responses

Checking your session.

Loading responses.