Governor Limits: The 101 Error and CPU Time Limit in Apex
Salesforce enforces governor limits to ensure no single transaction monopolises shared multi-tenant resources. Two limits account for the vast majority of production incidents: the SOQL 101 error (too many queries in one transaction) and the CPU time limit (too much processing time). Both are preventable once you understand exactly what triggers them.
The SOQL 101 Error
Every synchronous Apex transaction is allowed a maximum of 100 SOQL queries.
The 101st query throws a non-catchable System.LimitException and rolls back the
entire transaction. The error message is always the same:
System.LimitException: Too many SOQL queries: 101
What Causes It
The classic cause is a SOQL query inside a for loop. A trigger that fires on
200 records and runs one query per record hits 200 queries — double the limit — before
finishing. This pattern is called an N+1 query problem.
// BROKEN — one SOQL per Account record
trigger AccountTrigger on Account (after insert) {
for (Account acc : Trigger.new) {
List<Contact> contacts = [
SELECT Id, FirstName, LastName
FROM Contact
WHERE AccountId = :acc.Id
];
// process contacts...
}
}
On a batch import of 200 Accounts this fires 200 queries, crashing at query 101. The same pattern appears in Apex classes called from flows, anonymous Apex, or LWC controllers — anywhere a collection is iterated and a query lives inside the loop.
How to See It in Debug Logs
Open Setup → Debug Logs, reproduce the error, then open the log. Search for
LIMIT_USAGE_FOR_NS. The log section at the end of the transaction shows every
governor limit consumed:
LIMIT_USAGE_FOR_NS|(default)|
Number of SOQL queries: 101 out of 100
Number of DML statements: 3 out of 150
Number of DML rows: 200 out of 10000
The line immediately before the LimitException in the stack trace gives the
exact Apex class and line number where the 101st query executed.
Fix 1: Bulkify — Move Queries Outside Loops, Use Maps
The fix is to collect all IDs first, run one query outside the loop, then index the results
in a Map for O(1) lookup.
// FIXED — one SOQL for all records, Map lookup inside the loop
trigger AccountTrigger on Account (after insert) {
// 1. Collect all Account IDs from the trigger batch
Set<Id> accountIds = new Set<Id>();
for (Account acc : Trigger.new) {
accountIds.add(acc.Id);
}
// 2. One query outside the loop — fetches all related Contacts at once
Map<Id, List<Contact>> contactsByAccountId = new Map<Id, List<Contact>>();
for (Contact c : [
SELECT Id, FirstName, LastName, AccountId
FROM Contact
WHERE AccountId IN :accountIds
]) {
if (!contactsByAccountId.containsKey(c.AccountId)) {
contactsByAccountId.put(c.AccountId, new List<Contact>());
}
contactsByAccountId.get(c.AccountId).add(c);
}
// 3. O(1) Map lookup — no SOQL inside the loop
for (Account acc : Trigger.new) {
List<Contact> contacts = contactsByAccountId.get(acc.Id);
if (contacts == null) {
contacts = new List<Contact>();
}
// process contacts for this Account...
}
}
This pattern works regardless of batch size. Whether the trigger fires on 1 record or 200, it always executes exactly one SOQL query.
Fix 2: Lazy Loading with Static Variables
When the same query result is needed across multiple methods in the same transaction, cache
it in a static variable. A static variable is initialised once per transaction
and survives across method calls within that transaction.
public class AccountService {
// Cached once per transaction; null means not yet loaded
private static Map<Id, Account> cachedAccounts;
public static Map<Id, Account> getAccounts(Set<Id> ids) {
if (cachedAccounts == null) {
cachedAccounts = new Map<Id, Account>([
SELECT Id, Name, Industry, AnnualRevenue
FROM Account
WHERE Id IN :ids
]);
}
return cachedAccounts;
}
}
Any code in the same transaction that calls AccountService.getAccounts()
receives the already-populated map without issuing another query. The query runs at most once
per transaction, no matter how many callers invoke the method.
Governor Limit Table: Sync vs Async
Async contexts (Queueable, Batch, Scheduled, Future) receive significantly higher limits. Moving heavy processing into async jobs is a legitimate scaling strategy.
| Resource | Synchronous | Asynchronous |
|---|---|---|
| SOQL queries | 100 | 200 |
| SOQL query rows returned | 50,000 | 50,000 |
| DML statements | 150 | 150 |
| DML rows | 10,000 | 10,000 |
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 MB |
| Callouts | 100 | 100 |
| Future method calls per transaction | 50 | 0 (cannot call from async) |
The CPU Time Limit
Salesforce measures CPU time as the time your Apex code spends executing on the application server — it excludes network I/O (SOQL, callouts) but includes everything else: string operations, list sorting, regular expressions, loop iterations, and any computation you write. The limit is 10,000 ms synchronous and 60,000 ms asynchronous.
What Consumes CPU Time
- String concatenation and manipulation inside loops (use
List<String>+String.join()instead of+=) - Sorting large lists, especially with custom comparators
JSON.serialize()andJSON.deserialize()on large object graphs- Regular expression matching (
PatternandMatcher) - Deeply nested loops processing thousands of records
- Re-computing the same value on every loop iteration
Measuring CPU Time Mid-Execution
System.Limits.getCpuTime() returns the milliseconds consumed so far in the
current transaction. Use it at checkpoints to detect runaway processing before the limit
is hit.
public class HeavyProcessor {
private static final Integer CPU_WARN_THRESHOLD_MS = 7000; // 70% of sync limit
private static final Integer CPU_BAIL_THRESHOLD_MS = 9000; // 90% of sync limit
public static void processRecords(List<SObject> records) {
List<SObject> processed = new List<SObject>();
List<SObject> deferred = new List<SObject>();
for (SObject record : records) {
Integer cpuUsed = System.Limits.getCpuTime();
if (cpuUsed >= CPU_BAIL_THRESHOLD_MS) {
// Bail early — collect remaining records for async processing
deferred.add(record);
continue;
}
if (cpuUsed >= CPU_WARN_THRESHOLD_MS) {
// Optional: log a warning so engineers can investigate
System.debug(LoggingLevel.WARN,
'CPU at ' + cpuUsed + 'ms — approaching limit');
}
// Perform the actual processing
doExpensiveWork(record);
processed.add(record);
}
if (!deferred.isEmpty()) {
// Hand off to Queueable for records that didn't fit in this transaction
System.enqueueJob(new DeferredProcessorJob(deferred));
}
}
private static void doExpensiveWork(SObject record) {
// Simulate string-heavy processing
List<String> parts = new List<String>();
for (Integer i = 0; i < 50; i++) {
parts.add(String.valueOf(record.get('Name')) + '_' + i);
}
String result = String.join(parts, ',');
// further processing...
}
}
Pattern: Move Heavy Processing to Async (Queueable)
When a trigger or controller detects that processing will be expensive, enqueue the work instead of doing it synchronously. The Queueable interface gives you 60,000 ms CPU and runs outside the original transaction.
public class DeferredProcessorJob implements Queueable {
private List<SObject> records;
public DeferredProcessorJob(List<SObject> records) {
this.records = records;
}
public void execute(QueueableContext ctx) {
// Full 60,000 ms CPU budget available here
for (SObject record : records) {
HeavyProcessor.doExpensiveWork(record);
}
// Optionally chain another job if records are still remaining
// System.enqueueJob(new DeferredProcessorJob(nextBatch));
}
}
The trigger enqueues the job and returns immediately, staying well within the 10,000 ms sync limit. The Queueable executes separately with the higher async budget.
Pattern: Cache Computed Results in Static Variables
Any value computed repeatedly in the same transaction is wasted CPU. Cache it statically.
public class TaxRateHelper {
// Computed once; reused for every call in the same transaction
private static Map<String, Decimal> rateByCountry;
public static Decimal getRateForCountry(String countryCode) {
if (rateByCountry == null) {
rateByCountry = buildRateMap(); // expensive — runs once
}
return rateByCountry.containsKey(countryCode)
? rateByCountry.get(countryCode)
: 0.0;
}
private static Map<String, Decimal> buildRateMap() {
Map<String, Decimal> rates = new Map<String, Decimal>();
for (Tax_Rate__c rate : [SELECT Country_Code__c, Rate__c FROM Tax_Rate__c]) {
rates.put(rate.Country_Code__c, rate.Rate__c);
}
return rates;
}
}
Without caching, a loop processing 200 orders would call buildRateMap() 200
times. With the static cache, it runs once and serves all 200 iterations from memory.
Batch Apex for Large Volumes
When you need to process tens of thousands of records, Database.Batchable
splits the work into chunks (default 200 records per execute call), each with its own
governor limit budget.
public class AccountEnrichmentBatch implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, Industry, AnnualRevenue
FROM Account
WHERE RecordType.Name = 'Enterprise'
]);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
// Each execute() call gets a fresh set of governor limits
// scope.size() defaults to 200 records
List<Account> toUpdate = new List<Account>();
for (Account acc : scope) {
// CPU-intensive enrichment logic here — 60,000 ms budget per chunk
acc.Description = buildEnrichedDescription(acc);
toUpdate.add(acc);
}
update toUpdate;
}
public void finish(Database.BatchableContext bc) {
// Called once after all chunks complete
System.debug('Batch enrichment complete.');
}
private String buildEnrichedDescription(Account acc) {
return acc.Industry + ' | Revenue: ' + acc.AnnualRevenue;
}
}
Execute the batch from anonymous Apex or a Scheduled job:
Database.executeBatch(new AccountEnrichmentBatch(), 200);
Quick Reference: All Major Governor Limits
| Limit | Sync | Async | System.Limits Method |
|---|---|---|---|
| SOQL queries | 100 | 200 | getQueries() / getLimitQueries() |
| SOQL rows returned | 50,000 | 50,000 | getQueryRows() |
| SOQL rows for Database.getQueryLocator | 10,000 | 10,000 | getQueryLocatorRows() |
| DML statements | 150 | 150 | getDmlStatements() |
| DML rows | 10,000 | 10,000 | getDmlRows() |
| CPU time | 10,000 ms | 60,000 ms | getCpuTime() |
| Heap size | 6 MB | 12 MB | getHeapSize() |
| Callouts | 100 | 100 | getCallouts() |
| Callout time | 120 s total | 120 s total | — |
| Enqueued jobs | 50 | 1 (chain only) | getQueueableJobs() |
| Future calls | 50 | 0 | getFutureCalls() |
| Email invocations | 10 | 10 | getEmailInvocations() |
You can check any of these programmatically mid-execution. For example, to check remaining DML headroom before a bulk operation:
Integer dmlUsed = System.Limits.getDmlStatements();
Integer dmlLimit = System.Limits.getLimitDmlStatements();
Integer dmlRemaining = dmlLimit - dmlUsed;
if (dmlRemaining < 10) {
// Not enough DML budget left — defer this work
System.enqueueJob(new DeferredDmlJob(pendingRecords));
} else {
update pendingRecords;
}
Debugging: Reading the Limit Summary in Debug Logs
Every Apex transaction appends a CUMULATIVE_LIMIT_USAGE section at the end of
the debug log. This is the fastest way to diagnose which limit a transaction is approaching,
even when it hasn't failed yet.
- Go to Setup → Debug Logs.
- Add a trace flag for your user (or a specific Apex class) at FINEST level.
- Reproduce the operation — save a record, run a batch, call an endpoint.
- Open the log and search for
CUMULATIVE_LIMIT_USAGE.
The section looks like this:
CUMULATIVE_LIMIT_USAGE
LIMIT_USAGE_FOR_NS|(default)|
Number of SOQL queries: 8 out of 100
Number of SOQL query rows: 1450 out of 50000
Number of SOSL queries: 0 out of 20
Number of DML statements: 4 out of 150
Number of DML rows: 312 out of 10000
Maximum CPU time: 1843 out of 10000
Maximum heap size: 0 out of 6291456
Number of callouts: 0 out of 100
Number of Email Invocations: 0 out of 10
Number of future calls: 0 out of 50
Number of queueable jobs added to the queue: 1 out of 50
A transaction using 8 of 100 SOQL queries but 1843 of 10,000 ms CPU is trending toward a CPU problem, not a SOQL problem — even though both look fine right now. Running this on a larger data set would expose the CPU issue first. The log tells you where to look before production finds out for you.
For persistent CPU warnings, enable the Apex Profiling log category at INFO
level. This adds per-method timing lines (ENTERING_MANAGED_PKG,
CODE_UNIT_STARTED, CODE_UNIT_FINISHED) so you can see which
method consumed the most time in the call tree.
Putting It Together: A Fully Bulkified Trigger with CPU Awareness
The following trigger combines every pattern from this tutorial: a single query outside the loop, Map-based lookup, static variable caching, CPU checking, and async deferral.
trigger OpportunityTrigger on Opportunity (after insert, after update) {
OpportunityHandler.handleAfter(Trigger.new, Trigger.oldMap);
}
public class OpportunityHandler {
private static final Integer CPU_DEFER_THRESHOLD_MS = 8000;
// Static cache: AccountId → Account, populated once per transaction
private static Map<Id, Account> accountCache;
public static void handleAfter(
List<Opportunity> newRecords,
Map<Id, Opportunity> oldMap
) {
// 1. Collect Account IDs from the entire trigger batch
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : newRecords) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId);
}
}
// 2. One SOQL outside the loop, cached statically
if (accountCache == null) {
accountCache = new Map<Id, Account>([
SELECT Id, Name, Industry, OwnerId
FROM Account
WHERE Id IN :accountIds
]);
}
List<Opportunity> toUpdate = new List<Opportunity>();
List<Opportunity> toDefer = new List<Opportunity>();
// 3. Loop with CPU guard and Map lookup
for (Opportunity opp : newRecords) {
if (System.Limits.getCpuTime() >= CPU_DEFER_THRESHOLD_MS) {
toDefer.add(opp);
continue;
}
Opportunity old = (oldMap != null) ? oldMap.get(opp.Id) : null;
if (hasChanged(opp, old)) {
Account acc = accountCache.get(opp.AccountId);
Opportunity updated = enrichOpportunity(opp, acc);
toUpdate.add(updated);
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
if (!toDefer.isEmpty()) {
System.enqueueJob(new OpportunityDeferredJob(toDefer));
}
}
private static Boolean hasChanged(Opportunity newOpp, Opportunity oldOpp) {
if (oldOpp == null) return true;
return newOpp.StageName != oldOpp.StageName
|| newOpp.Amount != oldOpp.Amount;
}
private static Opportunity enrichOpportunity(Opportunity opp, Account acc) {
Opportunity result = new Opportunity(Id = opp.Id);
if (acc != null) {
result.Description = acc.Industry + ' deal — owner: ' + acc.OwnerId;
}
return result;
}
}
public class OpportunityDeferredJob implements Queueable {
private List<Opportunity> records;
public OpportunityDeferredJob(List<Opportunity> records) {
this.records = records;
}
public void execute(QueueableContext ctx) {
// Async context: 200 SOQL, 60,000 ms CPU
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : records) {
accountIds.add(opp.AccountId);
}
Map<Id, Account> accounts = new Map<Id, Account>([
SELECT Id, Name, Industry, OwnerId
FROM Account
WHERE Id IN :accountIds
]);
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity opp : records) {
Account acc = accounts.get(opp.AccountId);
toUpdate.add(OpportunityHandler.enrichOpportunity(opp, acc));
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
}
}
Summary
Governor limits are not obstacles — they are the contract that keeps Salesforce reliable
for every tenant on the platform. The SOQL 101 error almost always traces back to a query
inside a loop; fix it by collecting IDs first, querying once, and indexing results in a
Map. CPU limit violations come from repeated computation or excessive string
work across large collections; fix them by caching in static variables, avoiding recomputation,
and moving genuinely heavy work to Queueable or Batch Apex where the budget is six times
higher. Use System.Limits.getCpuTime() and System.Limits.getQueries()
as mid-execution checkpoints, and always read the CUMULATIVE_LIMIT_USAGE section
of your debug logs before assuming a transaction is healthy — the numbers tell the real story
before production does.
Frequently Asked Questions
Does the 100 SOQL query limit reset between trigger batches?
No — the limit is per transaction, not per batch chunk. If a trigger fires on 200 records in a single DML operation, all queries across the entire trigger execution count toward the same 100-query budget, which is why a query inside a loop hits the limit so quickly.
Can the System.LimitException from exceeding 100 SOQL queries be caught with a try-catch block?
No. LimitException is a non-catchable exception in Apex, meaning a try-catch block will not intercept it. When the 101st query executes, the entire transaction is rolled back immediately — no partial saves occur, and no custom error handling can prevent it.
Does the 100 SOQL query limit apply to asynchronous Apex like Batch jobs and Queueable classes?
Batch Apex gets 200 SOQL queries per execute() call, double the synchronous limit, because each chunk runs as its own transaction. However, bulkifying queries with Maps is still the correct pattern in async contexts — the higher limit is a safety margin, not a license to query inside loops.
How can the number of SOQL queries consumed so far in a transaction be checked programmatically?
The Limits class provides real-time governor limit data at runtime. Calling Limits.getQueries() returns the number of SOQL queries issued so far, and Limits.getLimitQueries() returns the maximum allowed (100 for synchronous). This is useful for defensive checks in utility methods that may be called from multiple code paths in the same transaction.