Bulkification, Mixed DML, and Lock Avoidance in Salesforce Apex
In Salesforce, your Apex code must handle anywhere from 1 to 200 records per transaction — all while coexisting with flows, triggers, and other automation.
Two foundational principles make this possible:
-
Bulkification — designing logic that efficiently processes lists of records without hitting governor limits.
-
Mixed DML and lock awareness — structuring your DML to avoid conflicts and transaction errors.
Master these, and your Apex will stay fast, resilient, and CI-friendly — no matter how large your org or how complex the automation stack.
? Bulkification with Collections
Goal: Efficiently process multiple records at once without exceeding limits.
In Apex, bulkification is all about thinking in collections: Lists, Sets, and Maps.
Core principles
-
Use Lists/Sets/Maps to gather IDs, deduplicate records, and relate data.
-
Move all SOQL and DML outside of loops — query once, update once.
-
Use Maps for joins and lookups (for example,
Map<Id, Account>to relate Opportunities). -
Group by parent and perform one update per parent per transaction.
Example mini-pattern
Set<Id> acctIds = new Set<Id>();
for (Opportunity o : Trigger.new) acctIds.add(o.AccountId);
// One query, map for O(1) lookups
Map<Id, Account> accts = new Map<Id, Account>([
SELECT Id, Won_Deals__c FROM Account WHERE Id IN :acctIds
]);
// Batch changes, one DML
List<Account> toUpdate = new List<Account>();
for (Opportunity o : Trigger.new) {
Account a = accts.get(o.AccountId);
// ... compute changes
toUpdate.add(a);
}
update toUpdate; // single DML, bulk-safe
Why this works:
By batching logic with Sets and Maps, you reduce queries and DML calls — which means fewer governor hits, faster execution, and safer scalability.
? Mixed DML — Setup vs. Non-Setup Objects
The problem:
Salesforce doesn’t allow DML on setup objects (like User, UserRole, PermissionSetAssignment) and non-setup objects (like Account, Contact, or Opportunity) in the same transaction.
Doing so throws the infamous MIXED_DML_OPERATION error.
The fix:
Simply split the work into separate transactions using one of the following async options:
-
Queueable Apex (recommended for flexibility)
-
@future methods (for legacy patterns)
-
Platform Events (for decoupled processing)
This keeps your data operations clean and compliant with Salesforce’s transaction model.
? Lock Avoidance (UNABLE_TO_LOCK_ROW)
Why it happens:
When two or more transactions try to update the same record — often a shared parent like an Account — Salesforce enforces a lock. If both compete for it, one loses and gets a UNABLE_TO_LOCK_ROW error.
How to avoid it:
-
Group by parent — update each parent record only once per transaction.
-
Process in consistent order (e.g., sort by AccountId) to reduce deadlock risk.
-
Minimize writes — only update fields that actually changed.
-
Retry gracefully — split large DML batches into smaller chunks on lock errors.
-
Avoid unnecessary
FOR UPDATEqueries unless you need strict locking control.
By keeping updates organized and predictable, you dramatically reduce lock contention and improve throughput.
? Real-World Example
Scenario
When an Opportunity moves to Closed Won, you need to:
-
Update its related Account with a new deal count (
Won_Deals__c) and latest close date. -
Assign a Permission Set to the Opportunity Owner (a setup DML, so must run asynchronously).
-
Prevent lock errors by grouping updates by Account and processing in consistent order.
Trigger — Lightweight, Just Delegates
// OpportunityTrigger.trigger
trigger OpportunityTrigger on Opportunity (after update) {
if (Trigger.isAfter && Trigger.isUpdate) {
new OpportunityTriggerHandler().afterUpdate(Trigger.oldMap, Trigger.newMap);
}
}
Handler — Bulkified Logic + Async Split for Mixed DML
// OpportunityTriggerHandler.cls
public with sharing class OpportunityTriggerHandler {
public void afterUpdate(Map<Id, Opportunity> oldMap, Map<Id, Opportunity> newMap) {
// 1) Filter to records that just became Closed Won
List<Opportunity> newlyWon = new List<Opportunity>();
for (Id id : newMap.keySet()) {
Opportunity oldO = oldMap.get(id);
Opportunity newO = newMap.get(id);
if (newO.StageName == 'Closed Won' && oldO.StageName != 'Closed Won') {
newlyWon.add(newO);
}
}
if (newlyWon.isEmpty()) return;
// 2) Bulkify: collect parent IDs and owner IDs
Set<Id> accountIds = new Set<Id>();
Set<Id> userIds = new Set<Id>();
for (Opportunity o : newlyWon) {
if (o.AccountId != null) accountIds.add(o.AccountId);
if (o.OwnerId != null) userIds.add(o.OwnerId);
}
// 3) Query accounts once
Map<Id, Account> acctById = new Map<Id, Account>([
SELECT Id, Won_Deals__c, Last_Won_Date__c
FROM Account WHERE Id IN :accountIds
]);
// 4) Aggregate per Account (lock-friendly)
Map<Id, Integer> wonCountAdd = new Map<Id, Integer>();
Map<Id, Date> lastWonDate = new Map<Id, Date>();
for (Opportunity o : newlyWon) {
if (o.AccountId == null) continue;
wonCountAdd.put(o.AccountId, 1 + (wonCountAdd.get(o.AccountId) == null ? 0 : wonCountAdd.get(o.AccountId)));
Date candidate = Date.newInstance(o.CloseDate.year(), o.CloseDate.month(), o.CloseDate.day());
Date current = lastWonDate.get(o.AccountId);
lastWonDate.put(o.AccountId, (current == null || candidate > current) ? candidate : current);
}
// 5) Prepare minimal Account updates (one per parent)
List<Account> acctUpdates = new List<Account>();
// Consistent ordering reduces deadlock risk
List<Id> ordered = new List<Id>(acctById.keySet());
ordered.sort();
for (Id aid : ordered) {
Account a = acctById.get(aid);
Integer add = wonCountAdd.get(aid);
Date newest = lastWonDate.get(aid);
if (add == null && newest == null) continue;
Account upd = new Account(Id = aid);
if (add != null) {
Integer existing = (a.Won_Deals__c == null ? 0 : (Integer)a.Won_Deals__c);
upd.Won_Deals__c = existing + add;
}
if (newest != null) {
// only set when it actually moves forward
if (a.Last_Won_Date__c == null || newest > a.Last_Won_Date__c) {
upd.Last_Won_Date__c = newest;
}
}
acctUpdates.add(upd);
}
// 6) Single DML for accounts; handle row lock retries by chunking
try {
if (!acctUpdates.isEmpty()) update acctUpdates;
} catch (DmlException ex) {
// naive chunk retry for lock timeouts
if (ex.getMessage().contains('UNABLE_TO_LOCK_ROW') && acctUpdates.size() > 1) {
Integer mid = acctUpdates.size() / 2;
update acctUpdates.subList(0, mid);
update acctUpdates.subList(mid, acctUpdates.size());
} else {
throw ex;
}
}
// 7) Mixed DML: grant permission set to owners in a **separate transaction**
System.enqueueJob(new GrantPermSetJob(new List<Id>(userIds), 'Revenue_Team'));
}
}
Async Job — Isolate Setup DML
// GrantPermSetJob.cls
public with sharing class GrantPermSetJob implements Queueable, Database.AllowsCallouts {
private List<Id> userIds;
private String permSetName;
public GrantPermSetJob(List<Id> userIds, String permSetName) {
this.userIds = userIds;
this.permSetName = permSetName;
}
public void execute(QueueableContext qc) {
if (userIds.isEmpty()) return;
PermissionSet ps = [
SELECT Id FROM PermissionSet WHERE Name = :permSetName LIMIT 1
];
List<PermissionSetAssignment> psa = new List<PermissionSetAssignment>();
for (Id uid : userIds) {
psa.add(new PermissionSetAssignment(
AssigneeId = uid,
PermissionSetId = ps.Id
));
}
// This DML is on a setup object and now runs in its own transaction → no mixed DML
Database.insert(psa, /* allOrNone */ false);
}
}
Optional: Lock-Safe Updater Utility
// LockSafeUpdater.cls
public with sharing class LockSafeUpdater {
public static void safeUpdate(List<SObject> records) {
if (records.isEmpty()) return;
try {
update records;
} catch (DmlException ex) {
if (ex.getMessage().contains('UNABLE_TO_LOCK_ROW') && records.size() > 1) {
Integer mid = records.size() / 2;
safeUpdate(records.subList(0, mid));
safeUpdate(records.subList(mid, records.size()));
} else {
throw ex;
}
}
}
}
⚡ Why This Works
-
Bulkification: All SOQL and DML operations are batched; parent lookups use Maps; and each Account is updated only once.
-
Mixed DML safety: The permission set assignment runs asynchronously in a separate transaction.
-
Lock avoidance: Consistent ordering and per-parent updates minimize locking conflicts; chunked retries handle edge cases gracefully.

? Final Thoughts
Design your Apex code to handle batches by default — even if you think it will only process one record.
-
Gather IDs with Sets.
-
Query related data once into Maps.
-
Perform a single DML per object type.
-
Split setup vs. non-setup DML into separate async transactions.
-
Reduce lock contention by updating parents only once, in a consistent order.
That’s how you write Apex that scales — reliable, efficient, and ready for production.
