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.
A System.LimitException cannot be caught and rolls back the whole
transaction, including DML that completed earlier in the same context —
so a trigger that runs its 101st query loses the insert that fired it.
This guide covers the limits that matter, the patterns that keep you
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 behind most 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
Processing more than 50,000 rows used to mean Batch Apex or the OFFSET
clause, which stops at 2,000 rows and slows down as the offset grows.
Apex Cursors (Database.getCursor) fix this: one cursor addresses up
to 50 million rows, fetch(position, count) returns a chunk, and a
chained Queueable carries the cursor and position forward. Each fetch
counts as a SOQL query and its rows count toward the 50,000-row limit,
so keep chunks well inside both; at most 100 fetch calls run per
transaction.
public with sharing class LargeDataProcessor implements Queueable {
private final Database.Cursor cursor;
private Integer position;
public LargeDataProcessor() {
this.cursor = Database.getCursor(
'SELECT Id, Name FROM Account ORDER BY Id',
AccessLevel.USER_MODE
);
this.position = 0;
}
public void execute(QueueableContext ctx) {
Integer remaining = cursor.getNumRecords() - position;
if (remaining <= 0) return;
List<Account> chunk = cursor.fetch(position, Math.min(2000, remaining));
position += chunk.size();
for (Account acc : chunk) {
// your logic here
}
if (position < cursor.getNumRecords()) {
System.enqueueJob(this); // one child per executing Queueable
}
}
}
A System.TransientCursorException means the fetch can be retried; a
System.FatalCursorException means the cursor is gone and the job must
start again.
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
In v67.0 and later, a class with no sharing keyword runs with sharing. On earlier versions the default depended on the entry point:
Aura and LWC controllers already ran with sharing, while Apex REST
services, asynchronous classes, Visualforce controllers and other entry
points ran without sharing — those are the classes whose queries
change behaviour on a version bump. 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