Bulkification, Mixed DML, and Lock Avoidance in Salesforce Apex

Share

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:

  1. Bulkification — designing logic that efficiently processes lists of records without hitting governor limits.

  2. 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 UPDATE queries 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.

Bulkification everywhere


? 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.

  • October 21, 2025