Lookup skew is tens of thousands of records whose lookup field points at the same target record. Insert or update them in parallel and the platform locks that target, so concurrent transactions queue behind each other or fail with UNABLE_TO_LOCK_ROW. The fix is almost never a code change. Spread the records across more targets, or sort your load so records sharing a target land in the same batch.
How it happens without anyone deciding to do it
Someone creates a default. A picklist would have been the right control, but a lookup to a configuration object got built instead, and 95% of records point at the row called “Standard”. Or an integration cannot resolve a value and falls back to a catch-all. Harmless at low volume, right up until two integrations load at once.
The mechanism
Inserting or updating a record with a populated lookup makes Salesforce keep the relationship consistent, so it locks the parent for the duration of the transaction. Ten parallel Bulk API batches all pointing at the same parent means ten transactions wanting the same lock. One gets it. The others wait, and if they wait too long they fail.
The failure is intermittent by nature. Run the load serially and it works. Run it in parallel on a busy Monday and a few percent of rows fail, naming a parent nobody was editing.
It is not ownership skew, and it is not account data skew
Three problems get called “skew” and they have different fixes.
- Ownership skew is one user owning more than roughly 10,000 records. The cost lands when that user’s role changes or they are deactivated, because sharing recalculates across everything they own.
- Account data skew is more than roughly 10,000 child records under one Account. Account drives sharing for its children, so changing the account owner cascades a recalculation down the tree.
- Lookup skew is many records pointing at the same lookup target, on any object. It has nothing to do with sharing. It is lock contention during writes.
The distinction matters because the standard ownership skew fix, taking the user out of the role hierarchy, does nothing for lookup skew.
Finding it
Group by the lookup field and look at the top of the distribution.
SELECT Product__c, COUNT(Id)
FROM Case
GROUP BY Product__c
ORDER BY COUNT(Id) DESC
LIMIT 10
Do this for every lookup on every object you load into at volume. The tell is the shape, not the count: one value holding an order of magnitude more records than the next. Four hundred thousand records across 200 targets is fine. The same 400,000 with 350,000 on one target is a problem waiting for concurrency.
Fixes, in the order worth trying
Sort the load by the lookup field. Free, immediate, and it solves most production incidents. Records for a given target end up adjacent, so they share a batch and a transaction, and the lock is taken once rather than contended. Do this first.
Run the load serially. The Bulk API can process batches in serial mode rather than parallel. Slower, and it removes the contention entirely. The right answer for a nightly job with hours available.
Reduce the batch size. Smaller batches hold locks for less time, narrowing the window for a collision. Worth trying when sorting is not possible.
Spread the targets. If the catch-all exists because nobody modelled the real values, model them. Otherwise create several equivalent target records and distribute across them. Ugly, effective, and a last resort, because it puts a performance workaround into your data model.
Remove the lookup. The one nobody suggests. If the field holds a small set of values that never change and nobody needs a relationship to a real record, a picklist or a custom metadata reference does the same job with no locking. This is the right fix more often than people admit.
Designing so it does not happen
Before adding a lookup, ask what the distribution looks like at a million records. If the answer is “most will point at the same one”, you are choosing a lookup for its picklist-like convenience and inheriting its write behaviour. Watch defaults: a lookup with a default value is a skew generator, because defaults are what records get when nobody thought about it.
Frequently asked
Does this affect master-detail as well? Yes, and worse, because master-detail adds sharing inheritance and roll-up recalculation on top of the lock. A detail object with 200,000 children under one master is the worst version of it.
Why did it only start failing last month? Concurrency changed, or volume did. Contention is a function of how many transactions overlap, so one new integration is enough to surface skew that sat there for years.
Can Support raise the lock timeout? No. Design around it.
