Apex Governor Limits Explained: How to Write Salesforce Code That Never Fails in Production
By Firus Hanov · · 9 min read
Learn every major Salesforce Apex governor limit — SOQL, DML, CPU, heap, callouts — with code examples and bulkification patterns to write code that scales.

If you've been writing Apex for more than a week, you've probably seen it — that dreaded runtime error plastered across your screen:
System.LimitException: Too many SOQL queries: 101
Or maybe:
System.LimitException: Too many DML statements: 151
Welcome to the world of Salesforce Governor Limits. They're the rules of the game — and if you don't understand them, your code will fail in production, sometimes silently, sometimes spectacularly, always at the worst possible time. 🔥
In this guide, I'm going to break down every major governor limit you need to know, show you exactly why they exist, and walk you through the code patterns that ensure you never hit them again.
Why Do Governor Limits Exist?
Salesforce is a multi-tenant platform. That means your org shares the same servers, database, and processing power as thousands of other companies. Governor limits are Salesforce's way of making sure no single tenant can monopolize those shared resources.
Think of it like an apartment building with shared utilities. If one tenant runs every appliance at full blast 24/7, everyone else's power bill goes up. Governor limits are the circuit breakers that keep one bad actor from taking down the whole building.
This is actually a feature, not a bug. It forces you to write efficient, scalable code — which is exactly what enterprise software demands.
The Limits You Must Know
1. SOQL Query Limits
| Context | Limit |
|---|---|
| Synchronous (standard Apex) | 100 SOQL queries per transaction |
| Asynchronous (Batch, Queueable, Future) | 200 SOQL queries per transaction |
| Records returned per query | 50,000 |
| Total records across all queries | 50,000 |
The 100-query limit resets with every new Apex transaction. A "transaction" starts whenever Apex is invoked — a trigger firing, a button click, a scheduled job, etc.
The #1 mistake developers make: putting SOQL queries inside for loops.
// ❌ WRONG — this hits the query limit fast
for (Contact c : contacts) {
Account a = [SELECT Name FROM Account WHERE Id = :c.AccountId];
System.debug(a.Name);
}
If contacts has 101 records, this code throws LimitException: Too many SOQL queries: 101. Even with 10 records in a test org, this is a ticking time bomb waiting for your data to grow.
The fix — always query outside loops:
// ✅ CORRECT — one query, handles any volume
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
accountIds.add(c.AccountId);
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
for (Contact c : contacts) {
Account a = accountMap.get(c.AccountId);
System.debug(a.Name);
}
One SOQL query. Handles 1 record or 10,000 records the same way. This is the pattern that separates junior developers from senior ones. ✅
2. DML Statement Limits
| Context | Limit |
|---|---|
| DML statements per transaction | 150 |
| Records processed per transaction | 10,000 |
| Records in a single DML call | 10,000 |
DML stands for Data Manipulation Language — any insert, update, delete, upsert, or undelete operation.
Same anti-pattern applies: DML inside loops is the kiss of death.
// ❌ WRONG — 150 contacts = 150 DML statements = LimitException
for (Contact c : contacts) {
c.Title = 'Updated Title';
update c; // 1 DML per loop iteration
}
// ✅ CORRECT — 1 DML statement, no matter how many records
for (Contact c : contacts) {
c.Title = 'Updated Title';
}
update contacts; // Bulkified — single DML call
This is called bulkification — one of the most critical skills in Apex development. Any trigger, service, or utility class you write must assume it could receive 200 records at once (Salesforce's maximum batch size for triggers).
3. Heap Size Limits
| Context | Limit |
|---|---|
| Synchronous | 6 MB |
| Asynchronous | 12 MB |
The heap is your transaction's working memory — every variable, list, map, and object lives here. If you load too many records into memory at once, you'll hit the heap limit.
Smart heap management:
// After you're done with a collection, clear it to free heap
List<Account> accounts = [SELECT Id, Name FROM Account LIMIT 5000];
// ... process accounts ...
accounts.clear(); // Free up that memory
For processing massive datasets, use Database.QueryLocator in Batch Apex instead of loading everything into memory at once.
4. CPU Time Limits
| Context | Limit |
|---|---|
| Synchronous | 10,000 milliseconds (10 seconds) |
| Asynchronous | 60,000 milliseconds (60 seconds) |
CPU time measures how long your Apex code is actually executing calculations. It doesn't count time spent waiting on database queries — just your code logic.
The usual culprit is nested loops:
// ❌ WRONG — O(n²) complexity will kill CPU time with large datasets
for (Account acc : accounts) {
for (Contact c : allContacts) {
if (c.AccountId == acc.Id) {
// process...
}
}
}
// ✅ CORRECT — O(n) complexity with a Map lookup
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : allContacts) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
for (Account acc : accounts) {
List<Contact> relatedContacts = contactsByAccount.get(acc.Id);
// process...
}
Map-based lookups are O(1) — constant time regardless of list size. Nested loops are O(n²) — they slow down exponentially as your data grows.
5. Callout Limits
| Limit | Value |
|---|---|
| Callouts per transaction | 100 |
| Single callout timeout | 120 seconds |
| Response/request size | 12 MB |
HTTP callouts to external APIs have strict rules. The most important one: you cannot perform DML before a callout in the same transaction.
// ❌ ILLEGAL — DML before callout throws an error
insert myAccount;
Http http = new Http();
HttpResponse res = http.send(request); // ERROR: callout after DML
The reason: if the callout fails, Salesforce can't roll back the record that was already committed. The solution is to make callouts asynchronously using @Future or Queueable:
// ✅ CORRECT — DML first, then async callout
insert myAccount;
ExternalSyncService.syncAsync(myAccount.Id);
// In ExternalSyncService:
@Future(callout=true)
public static void syncAsync(Id accountId) {
// Now safe to make callouts
Http http = new Http();
HttpResponse res = http.send(request);
}
6. Async Apex Limits
These limits apply to background processing:
Future Methods:
- 50 future calls per transaction
- 250,000 future calls per 24 hours (or licenses × 200, whichever is greater)
Queueable Apex:
- 50 jobs per transaction (1 if synchronous)
- Chain depth: 5 levels in Developer Edition, unlimited in Enterprise
Batch Apex:
- 5 concurrent batch jobs at once (100 can be queued)
- Batch size: 1–2,000 records per execute method
- Up to 50 million records via
Database.QueryLocator
Scheduled Apex:
- 100 scheduled jobs per org
- Minimum interval of 1 hour
How to Check Your Limits Programmatically
Salesforce gives you the Limits class to check your usage in real time:
public class LimitChecker {
public static void logCurrentUsage() {
System.debug('=== GOVERNOR LIMIT USAGE ===');
System.debug('SOQL Queries: ' + Limits.getQueries() + ' / ' + Limits.getLimitQueries());
System.debug('DML Statements: ' + Limits.getDMLStatements() + ' / ' + Limits.getLimitDMLStatements());
System.debug('CPU Time (ms): ' + Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime());
System.debug('Heap Size (bytes): ' + Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize());
System.debug('Callouts: ' + Limits.getCallouts() + ' / ' + Limits.getLimitCallouts());
}
// Defensive pattern — check before querying
public static Boolean safeToQuery() {
return Limits.getQueries() < (Limits.getLimitQueries() - 5); // 5-query buffer
}
}
Add this to your debug logs during development to catch limit issues before they reach production.
The 5 Rules of Writing Governor-Limit-Safe Apex
Let me summarize everything above into five rules you can commit to memory:
Rule 1: Never put SOQL inside a loop. Query before the loop, store results in a Map, then look up inside the loop.
Rule 2: Never put DML inside a loop. Collect all records in a list during the loop, then do one DML call after.
Rule 3: Always write bulkified code. Assume every method could receive 200+ records. Methods should accept List<SObject> — never a single record.
Rule 4: Use async patterns for heavy processing. If your logic is complex, CPU-intensive, or needs callouts, push it to Batch Apex, Queueable, or @Future.
Rule 5: Test with 200 records. In your Apex test classes, always test bulk scenarios with 200 records — the maximum trigger batch size. If it works with 200, it scales.
Practical Example: Refactoring a Limit-Violating Trigger
Here's a real-world scenario: a trigger that creates a follow-up Task for every new Contact.
Before — broken code:
trigger ContactTrigger on Contact (after insert) {
for (Contact c : Trigger.new) {
Account acc = [SELECT Name FROM Account WHERE Id = :c.AccountId]; // SOQL in loop!
Task t = new Task(
WhatId = c.AccountId,
WhoId = c.Id,
Subject = 'Follow up with ' + acc.Name,
ActivityDate = Date.today().addDays(7)
);
insert t; // DML in loop!
}
}
This hits SOQL and DML limits after 100 and 150 records respectively.
After — production-ready code:
trigger ContactTrigger on Contact (after insert) {
ContactTriggerHandler.handleAfterInsert(Trigger.new);
}
public class ContactTriggerHandler {
public static void handleAfterInsert(List<Contact> newContacts) {
// 1. Collect all Account IDs (no SOQL yet)
Set<Id> accountIds = new Set<Id>();
for (Contact c : newContacts) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
// 2. Single SOQL query outside the loop
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
// 3. Build all Tasks in memory
List<Task> tasksToInsert = new List<Task>();
for (Contact c : newContacts) {
if (c.AccountId != null && accountMap.containsKey(c.AccountId)) {
Account acc = accountMap.get(c.AccountId);
tasksToInsert.add(new Task(
WhatId = c.AccountId,
WhoId = c.Id,
Subject = 'Follow up with ' + acc.Name,
ActivityDate = Date.today().addDays(7)
));
}
}
// 4. Single DML call
if (!tasksToInsert.isEmpty()) {
insert tasksToInsert;
}
}
}
Result: 1 SOQL query, 1 DML statement — regardless of whether 1 or 2,000 contacts are inserted. ✅
Summary
Governor limits aren't the enemy — they're the guardrails that make Salesforce code enterprise-grade. The developers who master them write code that scales from 10 records to 10 million without breaking a sweat.
Here's your quick reference cheat sheet:
| Limit | Sync | Async |
|---|---|---|
| SOQL queries | 100 | 200 |
| DML statements | 150 | 150 |
| Records per transaction | 10,000 | 10,000 |
| CPU time | 10 sec | 60 sec |
| Heap size | 6 MB | 12 MB |
| Callouts | 100 | 100 |
Master these numbers, internalize the bulkification patterns, and your code will never fail in production due to governor limits again.
Want a free code review? Book a session with ApexSensei and I'll personally review your trigger code for governor limit issues, performance bottlenecks, and best practice violations. Start practicing on ApexSensei →
Have questions about a specific limit you've hit? Drop them in the comments below — I respond to every one. 💬