Skip to content
November 3, 2025·Updated ·19 min read

Salesforce Governor Limits: Understanding, Monitoring, and Avoiding Them

A practical guide to Salesforce governor limits — the most critical ones, how to monitor your consumption, and patterns to stay well within bounds.

← Back

Governor limits are the rules Salesforce enforces to ensure no single org monopolizes shared platform resources. Understanding them is essential — hit them in production and you get exceptions, broken automations, and unhappy users.

Why Governor Limits Exist

Salesforce is a multi-tenant platform. Your org shares compute, database, and API resources with thousands of others. Governor limits prevent any one org from consuming disproportionate resources. They're not bugs — they're the architecture.

The Limits That Actually Bite

Per-Transaction Synchronous Limits

LimitValueCommon Trigger
SOQL queries100SOQL in a loop
DML statements150DML in a loop
DML rows10,000Bulk operations
SOQL rows50,000Retrieving full tables
Heap size6 MBStoring large datasets
CPU time10,000 msComplex loops, algorithms
Callouts100External API calls

Per-Transaction Asynchronous Limits (Batch, Future, Queueable)

LimitValue
SOQL queries200
DML statements150
DML rows10,000
Heap size12 MB (all asynchronous Apex)
CPU time60,000 ms

Org-Wide Daily Limits

LimitValue
Async Apex executions (Batch, Future, Queueable, Scheduled)250,000 or 200 × the number of user licenses, whichever is greater, over a rolling 24 hours
Email alerts (workflow rules, approvals, flows)1,000 per standard Salesforce license (15 in Developer Edition), capped at 2,000,000 per org
API callsVaries by edition (Developer: 15,000/day)

Check these org limits at any time with OrgLimits.getMap() in Apex or the REST /limits resource.

Monitoring Your Limits

In Apex Code

// Check remaining limits at any point in execution
System.debug('SOQL queries: ' + Limits.getQueries() + '/' + Limits.getLimitQueries());
System.debug('DML statements: ' + Limits.getDmlStatements() + '/' + Limits.getLimitDmlStatements());
System.debug('DML rows: ' + Limits.getDmlRows() + '/' + Limits.getLimitDmlRows());
System.debug('CPU time: ' + Limits.getCpuTime() + '/' + Limits.getLimitCpuTime());
System.debug('Heap: ' + Limits.getHeapSize() + '/' + Limits.getLimitHeapSize());

Proactive Guard Before Critical Operations

public class LimitGuard {
    public static Boolean hasSoqlCapacity(Integer needed) {
        return (Limits.getLimitQueries() - Limits.getQueries()) >= needed;
    }
 
    public static Boolean hasDmlCapacity(Integer needed) {
        return (Limits.getLimitDmlStatements() - Limits.getDmlStatements()) >= needed;
    }
 
    public static Boolean isHeapSafe(Integer safetyMarginBytes) {
        return (Limits.getLimitHeapSize() - Limits.getHeapSize()) > safetyMarginBytes;
    }
}
 
// System.LimitException can be neither instantiated nor caught:
// throw a custom exception the caller can handle instead.
public class InsufficientCapacityException extends Exception {}
 
// Usage
if (!LimitGuard.hasSoqlCapacity(5)) {
    throw new InsufficientCapacityException('Insufficient SOQL capacity to proceed.');
}

The Patterns That Prevent Most Violations

1. Never SOQL or DML Inside a Loop

This is the #1 cause of governor limit violations. One query per record = 201 queries for 201 records.

// ❌ SOQL in loop — hits limit at record 101
for (Opportunity opp : opportunities) {
    Account acc = [SELECT Name FROM Account WHERE Id = :opp.AccountId];
    // ... use acc
}
 
// ✅ Bulk-safe pattern
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : opportunities) accountIds.add(opp.AccountId);
 
Map<Id, Account> accountMap = new Map<Id, Account>(
    [SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
 
for (Opportunity opp : opportunities) {
    Account acc = accountMap.get(opp.AccountId);
    // ... use acc
}

2. Collect and Execute DML Once

// ❌ DML inside a loop
for (Contact c : contacts) {
    c.Status__c = 'Processed';
    update c; // 1 DML per record
}
 
// ✅ Collect then DML once
List<Contact> toUpdate = new List<Contact>();
for (Contact c : contacts) {
    c.Status__c = 'Processed';
    toUpdate.add(c);
}
if (!toUpdate.isEmpty()) update toUpdate; // 1 DML total

3. Use @future or Queueable for Callouts

A trigger cannot make a synchronous callout, and a callout after uncommitted DML in the same transaction fails ("You have uncommitted work pending"). Offload them:

public class ExternalSyncService {
 
    @future(callout=true)
    public static void syncToERP(List<Id> accountIds) {
        List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
        // ... make HTTP callouts
    }
}
 
// Trigger invocation
trigger AccountTrigger on Account (after insert) {
    ExternalSyncService.syncToERP(
        new List<Id>(new Map<Id, Account>(Trigger.new).keySet())
    );
}

4. Use Aggregate SOQL to Reduce Row Counts

// ❌ Retrieve 50k rows just to count them
List<Opportunity> all = [SELECT Id FROM Opportunity WHERE AccountId = :accId];
Integer count = all.size(); // uses 50k of your 50k row limit!
 
// ✅ Aggregate at the database level
Integer count = [SELECT COUNT() FROM Opportunity WHERE AccountId = :accId];

5. Manage Heap with Null Disposal

public void execute(Database.BatchableContext bc, List<Account> scope) {
    // Process first half
    processPhaseOne(scope);
 
    // Explicitly null large intermediate data to free heap before phase 2
    // (Salesforce GC will reclaim it during next allocation)
    List<Account> processed = null;
 
    // Process second phase with more heap available
    processPhaseTwo(scope);
}

Queueable Apex: Better Than @future

Queueable jobs are the modern replacement for @future — they support chaining and type-safe parameters:

public class SyncQueueable implements Queueable, Database.AllowsCallouts {
    private List<Id> recordIds;
 
    public SyncQueueable(List<Id> ids) {
        this.recordIds = ids;
    }
 
    public void execute(QueueableContext ctx) {
        // Do work — can make callouts (Database.AllowsCallouts)
        List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :recordIds];
        // ... process
 
        // Chain next job if more work to do
        if (moreWorkNeeded()) {
            System.enqueueJob(new SyncQueueable(nextBatch));
        }
    }
}
 
// Enqueue
System.enqueueJob(new SyncQueueable(accountIds));

Limit: Max 50 chained Queueable jobs per transaction. Max 2 Queueable jobs enqueued from a single synchronous transaction.

What To Do When You Hit a Limit

LimitException in Production

  1. Check debug logs — identify which line threw the exception
  2. Find the root cause — usually a trigger calling a method that does SOQL/DML in a loop
  3. Check recursion — is the trigger calling itself? Use a recursion guard based on the Ids already processed (a plain boolean skips the following 200-record chunks)
  4. Refactor — apply bulkification patterns
  5. Consider async — move heavy processing to Batch or Queueable

Common Scenario: Trigger + Record-Triggered Flow Double-Fire

// A trigger fires → a record-triggered Flow updates records → the trigger fires again
// Solution: a recursion guard based on the Ids already processed — a plain boolean
// would skip the following 200-record chunks (see the triggers article)
 
public class TriggerControl {
    // Key = trigger + context + Id: a record is processed once per context
    private static Set<String> processed = new Set<String>();
 
    public static Boolean markProcessed(String triggerName, Id recordId) {
        return processed.add(triggerName + ':' + Trigger.operationType + ':' + recordId);
    }
}
 
// In the trigger handler
List<Account> toProcess = new List<Account>();
for (Account acc : (List<Account>) Trigger.new) {
    if (TriggerControl.markProcessed('AccountTrigger', acc.Id)) {
        toProcess.add(acc);
    }
}
if (toProcess.isEmpty()) return;
// ... your logic, on toProcess only

Developer Console Limit Tracking

In Developer Console → Logs → open a debug log → click Execution Overview tab.

You'll see a chart of governor limit consumption throughout the transaction — useful for identifying which method is consuming the most SOQL queries or heap.

Summary Table: Anti-Patterns → Solutions

Anti-PatternProblemSolution
SOQL in loop100 query limitCollect IDs, query once with IN
DML in loop150 DML limitCollect records, DML once
Large SOQL without LIMIT50,000 row limitAdd WHERE, LIMIT, or use aggregate
Trigger recursionInfinite loopRecursion guard based on processed Ids
Callout from a triggerSynchronous callouts are not allowed in triggers, and fail after uncommitted DML ("uncommitted work pending")@future(callout=true) or Queueable + AllowsCallouts
Storing full SObject lists6MB heapUse ID sets/maps instead

Conclusion

Governor limits force you to write better code — bulkified, efficient, and mindful of resource consumption. The patterns here (bulk queries, single DML, async callouts, aggregate SOQL) are not workarounds — they're the foundation of professional Salesforce development. Apply them consistently and you'll rarely see a LimitException outside of tests.


Useful Resources:

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn