Salesforce

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() and JSON.deserialize() on large object graphs
  • Regular expression matching (Pattern and Matcher)
  • 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.

  1. Go to Setup → Debug Logs.
  2. Add a trace flag for your user (or a specific Apex class) at FINEST level.
  3. Reproduce the operation — save a record, run a batch, call an endpoint.
  4. 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.

← All articles

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.