Table of Contents
1 What Are Governor Limits and Why Do They Exist?
Salesforce runs as a multi-tenant platform — your organisation shares the same servers, database, and runtime infrastructure with thousands of other organisations. To ensure fair resource usage and protect platform stability, Salesforce enforces governor limits: hard caps on how many resources a single Apex transaction can consume.
If your code exceeds any governor limit, the platform immediately throws a System.LimitException (an uncatchable exception) and rolls back the entire transaction. There is no warning before the exception — the transaction is simply terminated. This makes understanding and respecting governor limits non-negotiable for any Salesforce developer.
What counts as a "transaction"?
A transaction is a single unit of execution that shares a limit pool. Examples of what constitutes one transaction include:
- A trigger firing on an insert of 200 records
- An anonymous Apex script executed via the Developer Console
- A single invocation of a future method
- A single batch execute() method call
- A single queueable job execution
- A Visualforce page request or a REST API call into an @RestResource class
Synchronous vs. asynchronous limits
Many governor limits have two tiers: a lower limit for synchronous execution (triggers, Visualforce controllers, REST endpoints) and a higher limit for asynchronous execution (future methods, queueable apex, batch apex, scheduled apex). Async contexts are given more headroom precisely because they run in the background and do not block the user.
2 Complete Limits Reference Table
The table below covers every frequently referenced governor limit, split into synchronous and asynchronous values. Memorise the synchronous column — it is what you hit in triggers, Visualforce, and REST controllers every day.
| Resource | Synchronous Limit | Asynchronous Limit | Category |
|---|---|---|---|
| SOQL queries per transaction | 100 | 200 | Query |
| Records returned by SOQL | 50,000 | 50,000 | Query |
| Records retrieved via Database.getQueryLocator | — | 50,000,000 | Query |
| SOSL searches per transaction | 20 | 20 | Query |
| Records returned by a single SOSL query | 2,000 | 2,000 | Query |
| DML statements per transaction | 150 | 150 | DML |
| DML rows processed per transaction | 10,000 | 10,000 | DML |
| Heap size | 6 MB | 12 MB | Memory |
| CPU time | 10,000 ms | 60,000 ms | CPU |
| Maximum execution time per transaction | 10 min | 10 min | CPU |
| HTTP callouts per transaction | 100 | 100 | Callout |
| Maximum callout timeout | 120 sec | 120 sec | Callout |
| Email invocations per transaction | 10 | 10 | |
| Single email messages per day (org-wide) | 1,000 per licence (max 1,000,000) | ||
| @future method calls per transaction | 50 | 0 (cannot enqueue from async) | Async |
| Queueable jobs added per transaction | 50 | 1 (chain limit) | Async |
| Batch Apex jobs in the Apex flex queue | 100 | Async | |
| Concurrent batch jobs in ACTIVE/PROCESSING state | 5 | Async | |
| Scheduled Apex jobs | 100 | Async | |
| Push notification messages per transaction | 10 | 10 | Misc |
| Describe sObject calls per transaction | 100 | 200 | Misc |
System.LimitException cannot be caught with a try/catch block. Once you exceed a governor limit, the transaction is dead — your only options are to prevent the violation in the first place through good design, or to split work across async boundaries.3 SOQL in Loops — The #1 Anti-Pattern
The most commonly violated governor limit in Salesforce development is the SOQL query limit, and it is almost always caused by a single mistake: issuing a SOQL query inside a for loop. Because a trigger can process up to 200 records in one invocation, a query inside the loop fires once per record — instantly consuming 200 of your 100-query budget and throwing a LimitException.
Anti-pattern: SOQL inside a loop
// WRONG — this fires one SOQL query per record in Trigger.new
// With 200 records, this immediately blows the 100-query limit
trigger OpportunityTrigger on Opportunity (after update) {
for (Opportunity opp : Trigger.new) {
// BAD: one query per iteration
Account acc = [
SELECT Id, Name, Industry
FROM Account
WHERE Id = :opp.AccountId
];
// ... do something with acc
}
}
Correct pattern: bulk SOQL before the loop, Map for lookup
// CORRECT — one SOQL query regardless of how many records trigger fires on
trigger OpportunityTrigger on Opportunity (after update) {
// Step 1: collect all AccountIds from the trigger batch in one pass
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId);
}
}
// Step 2: one bulk SOQL — returns ALL matching accounts
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds]
);
// Step 3: iterate with O(1) Map lookup — zero additional SOQL
for (Opportunity opp : Trigger.new) {
Account acc = accountMap.get(opp.AccountId);
if (acc != null) {
// ... do something with acc
}
}
}
Why Map<Id, SObject> is the canonical solution
The new Map<Id, Account>(queryResult) constructor is a Salesforce idiom that automatically builds a Map keyed by the record's Id field. This gives you O(1) lookup time inside your loop, uses exactly one SOQL query, and handles any number of records in the trigger batch — from 1 to 200.
for loop. Collect all the IDs you need before the loop, run one bulk query with WHERE Id IN :idSet, store results in a Map, and look up inside the loop.SOQL for loops — iterating over query results efficiently
When you need to process a large result set that approaches the 50,000 row limit, use the SOQL for loop syntax. It processes records in chunks of 200 without loading all results into heap at once, protecting you from both the 50,000 row limit and the 6 MB heap size limit simultaneously.
// SOQL for loop: processes in batches of 200 internally
// Avoids loading all rows into heap at once
for (List<Contact> batch : [
SELECT Id, Email, AccountId
FROM Contact
WHERE Email != null
]) {
// 'batch' is a List of up to 200 Contacts
// process each chunk here, then GC can reclaim the memory
for (Contact c : batch) {
// process individual record
}
}
4 DML in Loops — The #2 Anti-Pattern
The second most common governor limit violation is issuing DML statements inside a loop. The synchronous DML statement limit is 150 per transaction. If you perform an update, insert, or delete inside a loop that iterates over trigger records, you consume one DML statement per iteration and quickly exhaust the limit.
Anti-pattern: DML inside a loop
// WRONG — one DML statement per record; burns the 150-statement limit fast
trigger ContactTrigger on Contact (after insert) {
for (Contact c : Trigger.new) {
Task t = new Task();
t.WhoId = c.Id;
t.Subject = 'Follow up with new contact';
t.Status = 'Not Started';
t.Priority = 'Normal';
insert t; // BAD: one DML per loop iteration
}
}
Correct pattern: collect records, DML once outside the loop
// CORRECT — build all records in the loop, one DML statement at the end
trigger ContactTrigger on Contact (after insert) {
List<Task> tasksToInsert = new List<Task>();
for (Contact c : Trigger.new) {
Task t = new Task();
t.WhoId = c.Id;
t.Subject = 'Follow up with new contact';
t.Status = 'Not Started';
t.Priority = 'Normal';
tasksToInsert.add(t); // build the list in memory — no DML yet
}
// One DML statement inserts ALL tasks, regardless of batch size
if (!tasksToInsert.isEmpty()) {
insert tasksToInsert;
}
}
insert of a List with 5,000 records counts as 1 DML statement but 5,000 DML rows. Both limits apply independently. The 10,000 DML row limit means you can process up to 10,000 records across all DML operations in a single transaction.Handling partial failures with Database.insert
When you need to tolerate individual record failures without rolling back the entire operation, use Database.insert(records, false) (the allOrNone = false overload). This returns a list of Database.SaveResult objects you can inspect for per-record success/failure information.
List<Database.SaveResult> results =
Database.insert(tasksToInsert, false); // allOrNone = false
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
for (Database.Error err : results[i].getErrors()) {
System.debug('Error on record ' + i + ': ' + err.getMessage());
}
}
}
5 Using the System.Limits Class
Salesforce exposes the System.Limits class (also referenced without namespace as just Limits) to allow your Apex code to inspect its current resource consumption at runtime. Each limit has two methods: one that returns the amount consumed so far, and one that returns the maximum allowed.
| Resource | Current Usage | Maximum |
|---|---|---|
| SOQL queries | Limits.getQueries() |
Limits.getLimitQueries() |
| SOQL rows returned | Limits.getQueryRows() |
Limits.getLimitQueryRows() |
| DML statements | Limits.getDmlStatements() |
Limits.getLimitDmlStatements() |
| DML rows | Limits.getDmlRows() |
Limits.getLimitDmlRows() |
| Heap size (bytes) | Limits.getHeapSize() |
Limits.getLimitHeapSize() |
| CPU time (ms) | Limits.getCpuTime() |
Limits.getLimitCpuTime() |
| Callouts | Limits.getCallouts() |
Limits.getLimitCallouts() |
| @future calls | Limits.getFutureCalls() |
Limits.getLimitFutureCalls() |
| Email invocations | Limits.getEmailInvocations() |
Limits.getLimitEmailInvocations() |
| SOSL queries | Limits.getSoslQueries() |
Limits.getLimitSoslQueries() |
Building a diagnostic limit logger
A common pattern is a utility method that logs all current limits for debugging purposes. Call it at the start and end of key operations to understand exactly where your limits are going.
public class LimitLogger {
public static void logAll(String context) {
System.debug('=== Governor Limits @ ' + context + ' ===');
System.debug('SOQL Queries : ' + Limits.getQueries()
+ ' / ' + Limits.getLimitQueries());
System.debug('SOQL Rows : ' + Limits.getQueryRows()
+ ' / ' + Limits.getLimitQueryRows());
System.debug('DML Statements: ' + Limits.getDmlStatements()
+ ' / ' + Limits.getLimitDmlStatements());
System.debug('DML Rows : ' + Limits.getDmlRows()
+ ' / ' + Limits.getLimitDmlRows());
System.debug('Heap Size : ' + Limits.getHeapSize()
+ ' / ' + Limits.getLimitHeapSize() + ' bytes');
System.debug('CPU Time : ' + Limits.getCpuTime()
+ ' / ' + Limits.getLimitCpuTime() + ' ms');
}
// Defensive guard — warn if approaching SOQL limit
public static void guardQueries(Integer threshold) {
if (Limits.getQueries() >= threshold) {
System.debug(LoggingLevel.WARN,
'WARNING: ' + Limits.getQueries()
+ ' of ' + Limits.getLimitQueries()
+ ' SOQL queries consumed!');
}
}
}
6 Bulkification Patterns and Map-Based Lookups
Bulkification is the practice of designing Apex code to handle any number of records — from 1 to 200 in a trigger, or thousands in a batch — using a fixed, small number of SOQL queries and DML statements. It is the single most important skill for writing governor-limit-safe Salesforce code.
The three-phase trigger handler pattern
A well-structured trigger handler separates work into three phases: collect (gather all IDs and field values you will need), query (run all SOQL up front, store in Maps), and process (iterate records, use Map lookups, build lists for DML). This structure guarantees that you never issue SOQL or DML inside a loop.
public class OpportunityHandler {
public static void afterUpdate(
List<Opportunity> newList,
Map<Id, Opportunity> oldMap
) {
// ===== PHASE 1: COLLECT =====
Set<Id> accountIds = new Set<Id>();
Set<Id> changedOppIds = new Set<Id>();
for (Opportunity opp : newList) {
Opportunity oldOpp = oldMap.get(opp.Id);
// Only process opps where Stage changed to Closed Won
if (opp.StageName == 'Closed Won'
&& oldOpp.StageName != 'Closed Won') {
accountIds.add(opp.AccountId);
changedOppIds.add(opp.Id);
}
}
if (accountIds.isEmpty()) return;
// ===== PHASE 2: QUERY (all SOQL here, zero inside loops) =====
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, OwnerId, Rating
FROM Account
WHERE Id IN :accountIds]
);
// ===== PHASE 3: PROCESS + COLLECT DML =====
List<Account> accountsToUpdate = new List<Account>();
for (Opportunity opp : newList) {
if (!changedOppIds.contains(opp.Id)) continue;
Account acc = accountMap.get(opp.AccountId);
if (acc != null && acc.Rating != 'Hot') {
acc.Rating = 'Hot';
accountsToUpdate.add(acc);
}
}
// One DML statement for all updates
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}
}
Avoiding duplicate DML on the same record
When building lists for DML in a loop, you may add the same record multiple times if multiple opportunities share the same account. Use a Map<Id, SObject> as your accumulator instead of a List — the Map naturally deduplicates by ID, preventing the "DUPLICATE_VALUE" error and unnecessary DML rows.
// Use a Map keyed by Id to avoid duplicate records in DML lists
Map<Id, Account> accountsToUpdateMap = new Map<Id, Account>();
for (Opportunity opp : newList) {
Account acc = accountMap.get(opp.AccountId);
if (acc != null) {
acc.Rating = 'Hot';
accountsToUpdateMap.put(acc.Id, acc); // safe — duplicates overwrite
}
}
if (!accountsToUpdateMap.isEmpty()) {
update accountsToUpdateMap.values();
}
WHERE Id IN :set or similar, (4) Results stored in Maps for O(1) lookup, (5) All DML uses Lists/Maps and fires once after the loop.7 Async Apex and Governor Limits Reset
One of the most powerful tools for working around governor limits is crossing an asynchronous boundary. When your synchronous transaction enqueues a future method, a queueable job, or a batch job, that new async context starts with a fresh, independent set of governor limits. The limit pool is not shared between the sync caller and the async callee.
Async limit increases at a glance
Future methods (@future)
Future methods run after the current transaction commits, in a separate async transaction with their own governor limits. They are ideal for making HTTP callouts (which cannot be mixed with DML in the same transaction) and for offloading CPU-heavy work. You can enqueue up to 50 future calls per synchronous transaction.
public class IntegrationService {
// callout=true required for HTTP callouts from @future
@future(callout=true)
public static void syncToExternalSystem(Set<Id> contactIds) {
// This runs in a new transaction — fresh 200 SOQL queries, 12 MB heap
List<Contact> contacts = [
SELECT Id, FirstName, LastName, Email
FROM Contact
WHERE Id IN :contactIds
];
// HTTP callout — allowed because callout=true and no prior DML in this tx
Http http = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/contacts');
req.setMethod('POST');
// ... build payload and send
}
}
Queueable Apex
Queueable jobs are the preferred replacement for @future methods. They can accept complex object parameters (not just primitives and collections), can be chained (one queueable enqueues another), and can be monitored via AsyncApexJob. A transaction can enqueue up to 50 queueable jobs; within a queueable context, you can only enqueue one child job (preventing infinite chains).
public class ProcessContactsQueueable implements Queueable {
private List<Id> contactIds;
public ProcessContactsQueueable(List<Id> ids) {
this.contactIds = ids;
}
public void execute(QueueableContext ctx) {
// Fresh governor limits: 200 SOQL, 150 DML, 12 MB heap, 60s CPU
List<Contact> contacts = [
SELECT Id, Email, LastName
FROM Contact
WHERE Id IN :contactIds
];
List<Contact> toUpdate = new List<Contact>();
for (Contact c : contacts) {
c.LastName = c.LastName.toUpperCase();
toUpdate.add(c);
}
update toUpdate;
// Chain next queueable if needed (limit: 1 from async context)
if (!Test.isRunningTest()) {
System.enqueueJob(new FollowUpQueueable(contactIds));
}
}
}
Batch Apex and the 5-concurrent-job limit
Batch Apex is the right tool for processing millions of records. Each execute() call processes a chunk (default 200, configurable up to 2,000) with its own set of governor limits. The critical operational limit to be aware of is that only 5 batch jobs can be in ACTIVE or PROCESSING state concurrently in a single org. You can have up to 100 jobs in the flex queue (HOLDING state) waiting to run.
start() method of a batch, using Database.getQueryLocator allows you to process up to 50 million records — far beyond the normal 50,000 SOQL row limit. This is the primary way to iterate over very large data sets without hitting row limits.8 Governor Limits in Apex Tests
Writing Apex tests that properly account for governor limits is essential for ensuring your code works correctly when deployed to production. There are two key concepts: using Test.startTest() / Test.stopTest() to get a fresh limit pool, and using the Limits class to assert that your code is consuming the expected number of queries and DML statements.
Test.startTest() and Test.stopTest()
Calling Test.startTest() resets all governor limit counters, giving the code between startTest() and stopTest() its own fresh set of limits. This is important for two reasons: it isolates your test setup code (which may consume queries) from the governor limits your actual production code is tested against, and it forces all async operations enqueued between the two calls to complete synchronously when stopTest() is called.
@isTest
private class OpportunityHandlerTest {
@TestSetup
static void makeData() {
// Setup queries/DML here do NOT count against the test's limits
Account acc = new Account(Name='Test Account');
insert acc;
Opportunity opp = new Opportunity(
Name='Test Opp', AccountId=acc.Id,
StageName='Prospecting', CloseDate=Date.today().addDays(30)
);
insert opp;
}
@isTest
static void testClosedWonUpdatesAccountRating() {
Opportunity opp = [SELECT Id, StageName FROM Opportunity LIMIT 1];
Test.startTest();
// Limits reset here — code under test gets a clean 100 SOQL pool
opp.StageName = 'Closed Won';
update opp;
Test.stopTest();
// Async jobs (future/queueable) enqueued in startTest complete at stopTest
// Assertions run after async completes
Account updatedAcc = [SELECT Rating FROM Account LIMIT 1];
System.assertEquals('Hot', updatedAcc.Rating,
'Account Rating should be Hot after Opp closes');
}
}
Asserting governor limit consumption in tests
Beyond testing functional correctness, you can use the Limits class inside your tests to assert that your bulkified code consumes an expected, bounded number of queries — regardless of the number of records processed. This is how you prove your code is truly bulkified.
@isTest
static void testBulkTriggerUsesFixedQueryCount() {
// Insert 200 contacts to simulate a full trigger batch
Account acc = [SELECT Id FROM Account LIMIT 1];
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < 200; i++) {
contacts.add(new Contact(
LastName='Test ' + i, AccountId=acc.Id
));
}
Test.startTest();
insert contacts; // fires ContactTrigger on 200 records
Integer queriesUsed = Limits.getQueries();
Test.stopTest();
// A properly bulkified trigger uses a small fixed number of queries
// regardless of batch size — assert it is less than 10, not 200
System.assert(queriesUsed < 10,
'Expected <10 queries for bulkified trigger, got: ' + queriesUsed);
}
Understanding the @TestSetup annotation
The @TestSetup annotation marks a method that creates shared test data for all test methods in the class. The data is rolled back between tests but re-created fresh for each one. Critically, the SOQL queries and DML statements issued inside @TestSetup do not count against the governor limits of your individual test methods — giving you more headroom to test your production code paths.
@TestSetup wisely and ensure your test data creation is efficient.Practice Governor Limits on ApexArena
Reinforce your understanding of governor limits by solving real Apex coding challenges. ApexArena grades your code for correctness and checks that your solution is properly bulkified.
Fix the SOQL in a Loop
Given a broken trigger with SOQL inside a loop, refactor it to use a single bulk query and a Map for efficient lookups.
Solve challenge →Batch DML Accumulator
Process 200 records, create related child records for each, and perform a single DML statement — without hitting the 150-statement limit.
Solve challenge →Runtime Limit Inspector
Build a utility class that logs all key governor limits before and after a heavy operation — using the System.Limits API.
Solve challenge →Offload to Queueable
Refactor a synchronous CPU-heavy operation into a Queueable job with chaining, getting fresh governor limits for each chunk.
Solve challenge →Frequently Asked Questions
More Salesforce Tutorials
Apex Triggers — Complete Guide
Events, context variables, bulkification, handler patterns, recursive guards.
Read tutorial →SOQL & SOSL Tutorial
Queries, relationships, aggregate functions, semi-joins, and best practices.
Read tutorial →Batch Apex Tutorial
Database.Batchable, start/execute/finish, chaining, and scheduling batches.
Read tutorial →Salesforce Apex Interview Questions
50+ frequently asked Salesforce developer interview questions and answers.
Read guide →