Apex

Apex Governor Limits — The Complete Guide (Summer '26)

Published 1 June 2026 · 14 min read · Intermediate

Salesforce runs thousands of organisations on shared infrastructure. Every time your Apex code fires, it shares the same database servers, CPU clusters, and memory pools as every other org on that instance. Governor limits exist because of this reality — they prevent one poorly written trigger from degrading performance for everyone else.

Understanding governor limits is not optional. Hitting one in a development org is an annoying inconvenience. Hitting one in production at 2am during a peak sales period is a career-defining incident. This guide covers every major limit, the patterns that keep you safely inside them, and the Summer ‘26 changes that affect how security and limits interact.

The essential limits you must memorise

Salesforce publishes a comprehensive limits reference, but in practice there are six numbers every developer should carry in their head.

LimitSynchronousAsynchronous
SOQL queries100200
SOQL rows returned50,00050,000
DML statements150300
DML rows processed10,00010,000
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB

Asynchronous contexts — Batch Apex, Queueable, @future methods — receive roughly double the allowance across most categories. Async jobs run in isolated execution containers where their resource consumption does not directly compete with interactive user sessions.

The anti-pattern that causes 80% of limit violations

The single most common cause of production limit violations is a SOQL query or DML statement inside a for loop. It looks harmless in development when you test with a single record, but it is catastrophically broken at scale.

// Anti-pattern — SOQL inside a loop
trigger ContactTrigger on Contact (after insert) {
    for (Contact c : Trigger.new) {
        // This fires one query per Contact
        // 200 Contacts = 200 queries = LimitException
        List<Account> accs = [
            SELECT Id, Name
            FROM Account
            WHERE Id = :c.AccountId
        ];
    }
}

The fix is bulkification — collect your IDs before the loop, query once outside it, store results in a Map, and reference the Map inside the loop.

// Bulkified pattern
trigger ContactTrigger on Contact (after insert) {
    Set<Id> accountIds = new Set<Id>();
    for (Contact c : Trigger.new) {
        if (c.AccountId != null) accountIds.add(c.AccountId);
    }

    Map<Id, Account> accountMap = new Map<Id, Account>(
        [SELECT Id, Name FROM Account WHERE Id IN :accountIds]
    );

    for (Contact c : Trigger.new) {
        Account related = accountMap.get(c.AccountId);
        // process safely — zero additional queries
    }
}

This handles one record or one million records with a single SOQL query and zero governor limit risk.

Monitoring limits at runtime with System.Limits

The System.Limits class lets you inspect consumption programmatically. This is invaluable in utility classes that may be called from multiple contexts with different remaining budgets.

public class QuerySafetyCheck {
    public static void assertQueryBudget(Integer queriesNeeded) {
        Integer used      = Limits.getQueries();
        Integer remaining = Limits.getLimitQueries() - used;

        if (remaining < queriesNeeded) {
            throw new QueryBudgetException(
                'Insufficient query budget. Used: ' + used +
                ', Needed: ' + queriesNeeded
            );
        }
    }
    public class QueryBudgetException extends Exception {}
}

When to go asynchronous

Moving work to an asynchronous context doubles most limits but does not remove them. The right reason to go async is when the work is genuinely non-blocking: the user does not need the result immediately and the operation is too heavy for a synchronous transaction.

Use @future for simple one-off callouts or heavy computations that need no chaining. Use Queueable for complex work that benefits from chaining and state passing. Use Batch Apex when you need the familiar start/execute/finish lifecycle over large datasets.

Apex Cursors for large datasets (GA — Spring ‘26)

Before Spring ‘26, processing datasets larger than 50,000 records required Batch Apex or the OFFSET clause — which had a ceiling of 2,000 records and degraded as the offset grew. Apex Cursors are now generally available and remain the recommended pattern in Summer ‘26.

public class LargeDataProcessor implements Queueable {
    private Database.Cursor cursor;

    public LargeDataProcessor(Database.Cursor cursor) {
        this.cursor = cursor;
    }

    public void execute(QueueableContext ctx) {
        List<Account> batch = cursor.fetch(2000);
        for (Account acc : batch) {
            // your logic here
        }
        if (!cursor.isAfterLast()) {
            System.enqueueJob(new LargeDataProcessor(cursor));
        }
    }

    public static void start() {
        Database.Cursor cursor = Database.getCursor(
            [SELECT Id, Name FROM Account ORDER BY Id]
        );
        System.enqueueJob(new LargeDataProcessor(cursor));
    }
}

Summer ‘26 security changes that affect limits (API v67.0)

Summer ‘26 introduces one of the most impactful Apex behavioural changes in recent memory. Two defaults that developers relied on for years have been reversed in API v67.0.

Default sharing mode is now with sharing

Prior to v67.0, a class without an explicit sharing declaration defaulted to without sharing, bypassing all record-level sharing rules. In v67.0 and later, the default flips to with sharing. Queries in undeclared classes now enforce the running user’s sharing rules. If a query previously returned 10,000 records but the running user can only see 200, upgrading that class to v67.0 will silently return 200 records instead.

The fix is simple: add explicit sharing declarations to every class before upgrading.

WITH SECURITY_ENFORCED is removed

The WITH SECURITY_ENFORCED SOQL clause is removed in API v67.0. Any class using it will fail to compile. Its replacement is WITH USER_MODE.

// Removed in API v67.0 — will not compile
List<Account> accs = [SELECT Id FROM Account WITH SECURITY_ENFORCED];

// Correct replacement
List<Account> accs = [SELECT Id FROM Account WITH USER_MODE];

Database operations default to user mode

In v67.0, all SOQL, SOSL, DML, and Database class methods run in user mode by default. Object permissions and field-level security are enforced automatically. Before upgrading any class to v67.0, audit every SOQL query that accesses sensitive or restricted fields and test under a restricted user profile.

Checklist before you hit a limit

  • Are all SOQL queries and DML statements outside every loop?
  • Are you using Maps for record lookups instead of nested loops with queries?
  • Have you called Limits.getQueries() in utility classes that may be called from multiple contexts?
  • For datasets above 50,000 records, are you using Apex Cursors?
  • For any class upgrading to API v67.0, have you added explicit sharing declarations and replaced WITH SECURITY_ENFORCED with WITH USER_MODE?

Governor limits are not obstacles — they are design feedback. When your code hits a limit, it is telling you something about the architecture. Treating limits as constraints that shape good design rather than walls to be worked around consistently produces cleaner, more scalable code.

Test your knowledge — Apex

10 questions · Basic to Advanced

0 / 10 correct

Frequently asked questions

What are Salesforce Apex governor limits?

Governor limits are hard per-transaction resource ceilings that Salesforce enforces on every Apex execution to protect shared infrastructure. They cover SOQL queries, DML statements, CPU time, heap size, and more — and cannot be increased by request.

What is the SOQL query limit in a synchronous Apex transaction?

Synchronous Apex is limited to 100 SOQL queries per transaction. Every query from every source in the transaction — triggers, Flows invoked from Apex, helper classes — counts against the same pool.

What exception is thrown when an Apex governor limit is exceeded?

Salesforce throws System.LimitException, which is uncatchable — a try-catch block will not intercept it. The entire transaction rolls back immediately.

What changed for Apex sharing defaults in API v67.0 (Summer '26)?

In API v67.0, Apex classes without an explicit sharing declaration now default to with sharing instead of the previous without sharing. Queries in undeclared classes now enforce the running user's sharing rules, which can silently return fewer records in upgraded code.

What replaced WITH SECURITY_ENFORCED in Summer '26?

WITH SECURITY_ENFORCED was removed in API v67.0 and will cause a compile error in any class using it. The replacement is WITH USER_MODE, which enforces field-level security and handles polymorphic fields correctly.

What are Apex Cursors and when should I use them?

Apex Cursors, generally available since Spring '26, enable server-side cursor-based pagination for datasets larger than 50,000 records using Queueable Apex. They replace the legacy OFFSET approach, which had a hard ceiling of 2,000 records and degraded in performance as the offset grew.

How do I check remaining governor limit budget at runtime in Apex?

Use the System.Limits class. For example, Limits.getQueries() returns the number of SOQL queries used so far, and Limits.getLimitQueries() returns the ceiling for the current context.