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.
| Limit | Synchronous | Asynchronous |
|---|---|---|
| SOQL queries | 100 | 200 |
| SOQL rows returned | 50,000 | 50,000 |
| DML statements | 150 | 300 |
| DML rows processed | 10,000 | 10,000 |
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 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_ENFORCEDwithWITH 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