Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Governor limits

Apex code is approaching a governor limit warning email

Salesforce sent your team an email saying Apex is approaching a governor limit (typically 80% of CPU, SOQL, DML, or callout caps) but didn't fail. This is an early warning — investigate before it becomes a hard limit failure.

Also seen asgovernor limit warning email·Apex governor warning·approaching governor limit·Apex limit notification email

An email lands in your inbox at 4:30 in the morning. The subject line reads "Approaching Apex CPU time limit warning". The body explains that your nightly batch job is using 92% of its CPU governor on a per-execution chunk. The job hasn't failed yet. Salesforce is telling you it's about to. By the time you finish coffee, you need to know whether the next run will detonate.

What the platform is actually doing

Salesforce monitors Apex governor usage in real time. When a piece of running code crosses an internal warning threshold (around 90% of a governor limit, varies by limit), the platform sends a warning email to the user identified in the Email of the relevant Apex Job. The job itself doesn't fail. The email is a heads-up: "you're getting close, look at this code before it breaks."

The warning email family covers multiple governors: CPU time, SOQL queries, SOQL query rows, DML statements, DML rows, heap size, callouts, future calls, queueable depth, email invocations, push notifications, view state size. Each governor has its own threshold and its own warning message.

The recipient of the email is determined by the running user (for synchronous Apex), the user listed as the Apex Job owner (for batch and queueable), or the email address set on the relevant async configuration. For batch Apex, this defaults to the user who scheduled the job. For triggers fired by integration users, the integration user's email is the destination.

The broken example

A batch class that scans every account in the org and updates a summary field:

public class AccountSummaryBatch implements Database.Batchable<SObject> {
    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator('SELECT Id FROM Account');
    }

    public void execute(Database.BatchableContext bc, List<Account> scope) {
        for (Account a : scope) {
            // SOQL inside a loop, one per row.
            Integer count = [SELECT COUNT() FROM Opportunity WHERE AccountId = :a.Id];
            a.Summary__c = String.valueOf(count);
        }
        update scope;
    }

    public void finish(Database.BatchableContext bc) {}
}

With a default batch size of 200, this fires 200 SOQL queries per execute invocation. The SOQL governor caps execute at 100 queries per transaction. The warning email fires at around 90 queries. The job survives the first chunk because the 200th query is the one that fails, not the 90th, but every chunk lives on the edge.

A second shape: a synchronous trigger doing heavy lookup work:

trigger AccountTrigger on Account (after update) {
    Map<Id, Account> updated = new Map<Id, Account>(Trigger.new);
    for (Account a : Trigger.new) {
        List<Contact> contacts = [SELECT Id, Name FROM Contact WHERE AccountId = :a.Id];
        for (Contact c : contacts) {
            // CPU-intensive string work per contact.
            String formatted = formatContactName(c.Name);
            // ... more work
        }
    }
}

Each iteration does a SOQL query and string processing. Update 50 accounts at once and you've blown both the SOQL count and the CPU limit. The warning email arrives just before the production update fails.

Three paths to a fix

Bulkify the queries. Replace per-row SOQL with one aggregated SOQL per chunk. Build a list of ids, query once, group results by parent id, and walk the parents iterating against the pre-fetched map.

Reduce the work inside each iteration. If you're doing string processing or computation on every record, see whether the result can be computed once outside the loop or cached. CPU is consumed in milliseconds; small loops with heavy bodies add up.

Decrease the batch chunk size. Batchable's Database.executeBatch(job, 100) lets you set chunk size. Smaller chunks fit inside governor budgets more easily but take longer to complete. If the work is CPU-bound and refactoring isn't feasible immediately, drop chunk size as a tactical fix while you plan the architectural one.

The fixed example

The batch class rewritten to bulkify the lookup:

public class AccountSummaryBatch implements Database.Batchable<SObject> {
    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator('SELECT Id FROM Account');
    }

    public void execute(Database.BatchableContext bc, List<Account> scope) {
        Set<Id> accountIds = (new Map<Id, Account>(scope)).keySet();

        // One aggregate query for all accounts in the chunk.
        Map<Id, Integer> oppCountByAccount = new Map<Id, Integer>();
        for (AggregateResult ar : [
            SELECT AccountId aid, COUNT(Id) cnt
            FROM Opportunity
            WHERE AccountId IN :accountIds
            GROUP BY AccountId
        ]) {
            oppCountByAccount.put((Id) ar.get('aid'), (Integer) ar.get('cnt'));
        }

        for (Account a : scope) {
            Integer count = oppCountByAccount.containsKey(a.Id)
                ? oppCountByAccount.get(a.Id)
                : 0;
            a.Summary__c = String.valueOf(count);
        }
        update scope;
    }

    public void finish(Database.BatchableContext bc) {}
}

One SOQL per execute, regardless of chunk size. CPU usage drops because the loop is now pure assignment. The warning email stops arriving.

What each governor warning actually means

The warning emails come in several flavors. Each one points to a different class of fix.

CPU time approaching limit. Synchronous Apex has 10,000 milliseconds per transaction. Asynchronous Apex (batch, future, queueable) has 60,000 milliseconds. A warning at 90% means your code is using too much CPU. Profile with System.debug(Limits.getCpuTime()) to find the hotspot. Common culprits: nested loops, string concatenation in tight loops, regex on large strings, recursion.

SOQL queries approaching limit. Sync caps at 100, async at 200. Almost always means SOQL inside a loop. Bulkify.

SOQL query rows approaching limit. 50,000 rows per transaction. Aggregate queries with COUNT() return one row per group, not per matching record. If you're hitting this on a SELECT that returns 50,000 rows, restructure to summarize on the database side.

Heap size approaching limit. Sync caps at 6MB, async at 12MB. Means you've loaded too much into memory. Common with large SOQL result sets, large string concatenation, or unbounded collection growth. The fix is usually to stream results (use SOQL for-loops) or process in chunks.

Email invocations approaching limit. 10 single emails or 10 mass emails per transaction. Building a notification flow that emails everyone in a list defeats this cap quickly.

Future or Queueable depth. You can chain async, but recursion limits apply. Warnings here often mean an infinite loop in your async chain.

Diagnosing without waiting for the email

Add Limits instrumentation at strategic points in your code:

public static void instrumentLimits(String checkpoint) {
    System.debug(LoggingLevel.INFO,
        '[' + checkpoint + '] '
        + 'CPU: ' + Limits.getCpuTime() + '/' + Limits.getLimitCpuTime() + ' '
        + 'SOQL: ' + Limits.getQueries() + '/' + Limits.getLimitQueries() + ' '
        + 'Rows: ' + Limits.getQueryRows() + '/' + Limits.getLimitQueryRows() + ' '
        + 'DML: ' + Limits.getDmlStatements() + '/' + Limits.getLimitDmlStatements() + ' '
        + 'Heap: ' + Limits.getHeapSize() + '/' + Limits.getLimitHeapSize()
    );
}

Call this at the start and end of each method in a chain. The diff between checkpoints tells you which method consumes which budget. Production-safe: it only logs, doesn't change behavior.

The Apex Debug Log (Setup, then Debug Logs) captures these in test contexts. For production, enable a User Trace Flag on a single user for a short window.

The 5-second rule for refactoring

A useful guideline: if a single batch execute or trigger fire takes longer than 5 seconds of CPU, your code is doing too much per record. The right architecture for "heavy work per record" is one of:

  • Pre-compute the heavy result in a nightly batch and store it on the record.
  • Defer heavy work to a queueable that processes one record at a time.
  • Restructure to do the heavy work less often (cache results, only recompute on change).

CPU time grows non-linearly with data volume. Code that takes 8 seconds for 100 records may take 80 seconds for 1,000 records and timeout entirely at 10,000. Don't extrapolate linearly.

Test patterns for governor awareness

A governor-budget test that runs a representative workload and asserts the limits used are within bounds:

@isTest
static void summaryBatch_staysWithinCpuBudget() {
    List<Account> accounts = new List<Account>();
    for (Integer i = 0; i < 200; i++) {
        accounts.add(new Account(Name = 'Test ' + i));
    }
    insert accounts;

    Test.startTest();
    AccountSummaryBatch job = new AccountSummaryBatch();
    Database.executeBatch(job, 200);
    Test.stopTest();

    System.assert(Limits.getCpuTime() < 6000,
        'CPU used: ' + Limits.getCpuTime() + ' (budget: 10000)');
    System.assert(Limits.getQueries() < 50,
        'SOQL count: ' + Limits.getQueries() + ' (budget: 100)');
}

The assertion margins (6000 of 10000 CPU, 50 of 100 SOQL) leave headroom for production data volumes to grow. If the assertion starts failing in CI, you have a clear signal to refactor before production blows up.

The connection to scheduled jobs

Scheduled Apex (Apex jobs scheduled via Setup, then Scheduled Jobs) runs in async context with a 60,000ms CPU budget. The warning emails for scheduled jobs go to the scheduler user.

If a scheduled job fires the warning email on every run, the job is at the edge. The two paths forward:

  • Refactor the job to use a Batch Apex inside, splitting work across many execute invocations each with their own budget.
  • Reduce the work the job does per run (process only changes since the last run instead of full scans).

The wrong path: increasing the schedule frequency to compensate. More frequent runs don't grow the per-run budget; they just spread the same work over more transactions, each of which still has the same risk profile.

When you can't reproduce the warning in a sandbox

Sandboxes often have less data than production. The same code runs comfortably in UAT and times out in production simply because the data volume is bigger.

Two tactics:

Generate representative volumes in the sandbox before testing. A test setup utility that creates 100,000 accounts and 1,000,000 opportunities reveals scale-bound issues that small sandboxes hide.

Use Apex Replay Debugger to replay production logs locally. Capture a debug log of a production batch run, download it, replay step by step in the IDE. This catches scale issues without needing production data in a sandbox.

Related governor failures

The warning email family precedes a set of harder errors. Knowing the progression helps you prioritize:

Warning emailHard failure
CPU approaching limitSystem.LimitException: Apex CPU time limit exceeded
SOQL approaching limitSystem.LimitException: Too many SOQL queries: 101
Heap approaching limitSystem.LimitException: Apex heap size too large
DML approaching limitSystem.LimitException: Too many DML statements: 151
Email approaching limitSystem.LimitException: Too many Email Invocations

The warning is a free reminder. The hard failure costs you a production incident.

Disabling the warnings (not recommended)

You can mute the warning emails for a specific user by editing their email preferences. Don't. The warning is doing its job; muting it just means you'll find out about the problem from a user instead of a self-monitor.

A better pattern: route warning emails to a shared inbox or a Slack channel via an email-to-channel integration. The team sees the warning collectively. Whoever has bandwidth that day responds.

Habits that prevent warnings

Three habits eliminate most warning emails over time.

Profile every new Apex class with Limits instrumentation before merging. The PR template can include a checklist item: "Limits at the top and bottom of every public method, captured for typical workload." A short comment in the PR description listing the observed CPU, SOQL, and heap costs makes future reviewers' lives easier and creates a trail of expected baseline usage that you can compare against when production behaves unexpectedly.

Set up a CI step that runs your governor-budget tests on every merge. The assertions catch creeping regressions. A test that asserted "CPU under 4000" three months ago and now asserts "CPU under 7000" is telling you that someone bumped the threshold to make the test pass instead of fixing the underlying inefficiency. Tracking the assertion values themselves over time gives you a regression signal you can act on before production does.

Maintain a list of "high-volume Apex" classes and review them quarterly against production data growth. Code that was fine at 10,000 records may need refactoring at 100,000. The review is short: pull the production data volume, compare to the test volume, decide whether the gap is still safe. If you find a class that's been "approximately fine" for two quarters, schedule the refactor before the third quarter forces an emergency one.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Governor limit errors