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
| Limit | Value | Common Trigger |
|---|---|---|
| SOQL queries | 100 | SOQL in a loop |
| DML statements | 150 | DML in a loop |
| DML rows | 10,000 | Bulk operations |
| SOQL rows | 50,000 | Retrieving full tables |
| Heap size | 6 MB | Storing large datasets |
| CPU time | 10,000 ms | Complex loops, algorithms |
| Callouts | 100 | External API calls |
Per-Transaction Asynchronous Limits (Batch, Future, Queueable)
| Limit | Value |
|---|---|
| SOQL queries | 200 |
| DML statements | 150 |
| DML rows | 10,000 |
| Heap size | 12 MB (all asynchronous Apex) |
| CPU time | 60,000 ms |
Org-Wide Daily Limits
| Limit | Value |
|---|---|
| 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 calls | Varies 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 total3. 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
- Check debug logs — identify which line threw the exception
- Find the root cause — usually a trigger calling a method that does SOQL/DML in a loop
- 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)
- Refactor — apply bulkification patterns
- 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 onlyDeveloper 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-Pattern | Problem | Solution |
|---|---|---|
| SOQL in loop | 100 query limit | Collect IDs, query once with IN |
| DML in loop | 150 DML limit | Collect records, DML once |
| Large SOQL without LIMIT | 50,000 row limit | Add WHERE, LIMIT, or use aggregate |
| Trigger recursion | Infinite loop | Recursion guard based on processed Ids |
| Callout from a trigger | Synchronous callouts are not allowed in triggers, and fail after uncommitted DML ("uncommitted work pending") | @future(callout=true) or Queueable + AllowsCallouts |
| Storing full SObject lists | 6MB heap | Use 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: