Salesforce Governor Limits Explained With Code You Can Run
A plain-English guide to Salesforce governor limits: per-transaction caps on SOQL, DML, CPU, and heap, with runnable Apex that prints your real numbers using Limits.
TL;DR
Salesforce runs every Apex transaction inside a tight budget called governor
limits. Each transaction gets a fixed cap on SOQL queries, DML statements,
CPU time, heap size, and more. If your code crosses a cap, the transaction
throws a runtime exception and rolls back. Write bulkified code, move heavy
work out of loops, and check Limits instead of guessing.
Why Governor Limits Exist
Salesforce is a multi-tenant platform. Your code shares one database with every other customer's org. Without limits, one bad script could lock the whole platform. Governor limits are the line that prevents that.
The other reason they matter: they force you to write code that works for 200 records the same way it works for one. The platform hands you 200 records on every trigger, every batch, every REST call. Code that only handles one record is buggy code waiting to break in production.
The Limits You Will Hit First
Most beginners hit the same handful of limits. Learn them now and you save yourself weeks of confused debugging.
| Limit | Per-transaction cap (sync) | What it covers |
|---|---|---|
| SOQL queries | 100 | Each SELECT counts. Subqueries count too. |
| SOQL rows retrieved | 50,000 | Total rows returned across all queries. |
| DML statements | 150 | Each insert, update, delete, upsert, undelete. |
| DML rows | 10,000 | Total rows touched across all DML. |
| CPU time | 10,000 ms | Real wall-clock-ish time on the server. |
| Heap size | 6 MB | Memory your script can hold. |
| Callouts | 100 | Outbound HTTP requests. |
| Future calls | 50 | @future queue. |
| Queueable jobs | 50 | System.enqueueJob per transaction. |
| Batch size | 200 | Records per execute invocation. |
You can read the full list any time with Limits.getLimitMap(). Every limit
exposes a getLimit(name) and a getConsumed(name) method.
Read Your Own Numbers With Limits
The cleanest debugging habit you can build is asking the platform how much budget you have left. Stop guessing, start reading.
public class GovernorBudgetPrinter {
public static void printBudget() {
System.debug('SOQL queries used : ' +
Limits.getQueries() + ' / ' + Limits.getLimitQueries());
System.debug('SOQL rows retrieved : ' +
Limits.getQueryRows() + ' / ' + Limits.getLimitQueryRows());
System.debug('DML statements used : ' +
Limits.getDmlStatements() + ' / ' + Limits.getLimitDmlStatements());
System.debug('DML rows touched : ' +
Limits.getDmlRows() + ' / ' + Limits.getLimitDmlRows());
System.debug('CPU time (ms) : ' +
Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime());
System.debug('Heap size (bytes) : ' +
Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize());
}
}Drop that class in your org, call GovernorBudgetPrinter.printBudget() from
anonymous Apex or a trigger, and you see your real numbers in the debug log.
No docs, no guessing.
The Classic Mistake: SOQL In A Loop
The most common beginner bug is also the one governor limits punish the hardest. The code below looks fine. It runs once for one account and returns the right answer. Pass it 200 accounts and it dies.
public class BadAccountUpdater {
public static void renameAccounts(List<Account> accounts) {
for (Account acc : accounts) {
// SOQL inside the loop. 200 accounts = 200 queries = LIMIT_EXCEEDED.
List<Contact> contacts = [
SELECT Id, LastName
FROM Contact
WHERE AccountId = :acc.Id
];
acc.Description = 'Has ' + contacts.size() + ' contacts';
}
}
}Run that against 200 accounts and you throw
System.LimitException: Too many SOQL queries: 101. The fix is one query
before the loop, then a map for the lookups.
The Fix: One Query, A Map, Bulk DML
The working version queries once, groups the results into a map, then walks the input list. 200 records cost 1 SOQL query and 1 DML update, no matter how many records you pass in.
public class GoodAccountUpdater {
public static void renameAccounts(List<Account> accounts) {
if (accounts == null || accounts.isEmpty()) {
return;
}
// 1 query, regardless of how many accounts you pass in.
List<Contact> contacts = [
SELECT Id, AccountId
FROM Contact
WHERE AccountId IN :accounts
ALL ROWS
];
// Bucket contacts by their account so the lookup is O(1).
Map<Id, Integer> countsByAccount = new Map<Id, Integer>();
for (Contact c : contacts) {
if (!countsByAccount.containsKey(c.AccountId)) {
countsByAccount.put(c.AccountId, 0);
}
countsByAccount.put(c.AccountId, countsByAccount.get(c.AccountId) + 1);
}
// Update only what we need to update.
for (Account acc : accounts) {
Integer count = countsByAccount.get(acc.Id);
if (count == null) {
acc.Description = 'Has no contacts';
} else {
acc.Description = 'Has ' + count + ' contacts';
}
}
// 1 DML statement, regardless of batch size.
update accounts;
}
}Three things changed: one SOQL outside the loop, a Map<Id, Integer> keyed
by the parent record, and one bulk DML at the end. That is the whole
"bulkification" idea in one example.
DML In A Loop Is The Same Bug
Same shape, different verb. Inserting contacts one at a time burns through your 150 DML statement cap long before it burns through your data.
public class BulkContactInserter {
public static void insertContacts(List<String> firstNames, List<String> lastNames) {
if (firstNames == null || firstNames.isEmpty()) {
return;
}
List<Contact> toInsert = new List<Contact>();
for (Integer i = 0; i < firstNames.size(); i++) {
toInsert.add(new Contact(
FirstName = firstNames[i],
LastName = lastNames[i]
));
}
// 1 DML statement, no matter how many rows.
insert toInsert;
}
}Build a list, insert once. The platform is built for that pattern.
When You Hit A Limit Anyway
Sometimes 10,000 rows really do not fit in one transaction. Salesforce ships four tools for that:
- Batch Apex — runs
executeover chunks of up to 200 records, with a fresh governor budget per chunk. Best for millions of records. - Queueable Apex — runs asynchronously, also with a fresh budget. Best for "I need this to run soon and I want a chain".
@futuremethods — fire-and-forget async. Lightweight but limited (no return value, no chained calls).Database.Statefulbatch — keeps instance variables across chunks. Use this when each chunk depends on the last.
A short queueable example, showing the same Limits query pattern from
before but inside async context:
public class CountContactsQueueable implements Queueable, Database.AllowsCallouts {
private List<Account> accounts;
public CountContactsQueueable(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext ctx) {
List<Contact> contacts = [
SELECT Id, AccountId
FROM Contact
WHERE AccountId IN :accounts
];
Map<Id, Integer> counts = new Map<Id, Integer>();
for (Contact c : contacts) {
counts.put(c.AccountId, (counts.get(c.AccountId) ?? 0) + 1);
}
for (Account acc : accounts) {
acc.Description = 'Has ' + (counts.get(acc.Id) ?? 0) + ' contacts';
}
update accounts;
// Useful while you are tuning: see the budget this run consumed.
System.debug('Async SOQL: ' + Limits.getQueries() + ' / ' +
Limits.getLimitQueries());
}
}Calling code: System.enqueueJob(new CountContactsQueueable(scopeList));
CPU time is the limit that catches people who already bulkified their
queries. A loop with a tight string or regex inside it can chew through
10,000 ms in a few thousand iterations. The fix is the same: chunk the
work, move the heavy loop into Batch Apex. Async contexts get a higher
budget (60,000 ms of CPU and 200 SOQL queries), but they are not infinite,
and the moment your queueable chains into another one the cap stays the
same. If you are debugging a slow job, log Limits.getCpuTime() at the
start and end of execute — the delta is how much your code actually cost,
which is rarely how long it felt.
Why Async Limits Are Different
Synchronous Apex caps you at 100 SOQL queries and 10 seconds of CPU. Async
Apex (Batch, Queueable, @future, Schedulable) doubles the SOQL cap to
200 and raises CPU to 60 seconds. Heap also jumps from 6 MB to 12 MB. The
tradeoff is latency: an async job runs when the platform has space, which
might be now or might be in an hour.
The classic trap is treating async like a magic "more limit" button. It
gives you headroom but it does not give you permission to write bad code.
A queueable that fires 50 more queueables in one transaction still has
the 50-callouts cap, the 12 MB heap, and the CPU budget. Read the same
Limits numbers you would read in sync code, just with different caps.
The Habit That Prevents Most Of This
Three rules cover 90 percent of "why did my code blow up":
- One SOQL outside the loop, keyed by
IN. Same fix for DML. - Use
Limits.getX()andLimits.getLimitX()in debug logs. Read the numbers, do not guess. - When in doubt, write a test that inserts 200 records. Triggers and batch classes run with 200 records in production. If your test does not reflect that, your test is lying to you.
A test that hits 200 records per trigger invocation is the only test that proves you are inside the budget.
Where To Practice Next
Start with the
governor limits query counter challenge
to see Limits.getQueries() in action inside a real assertion. Then jump to
bulk insert contacts for the other half
of the same idea, and follow up with the
bulk update account industry
challenge to wire both together. If you are studying for PD1, the whole
apex fundamentals path walks through these
patterns one at a time.
About Warren Walters
Salesforce MVP and transformative mentor with 8+ years in the Salesforce realm. Founder of Lightning Challenge, dedicated to nurturing the next generation of Salesforce talent through hands-on practice and real-world coding challenges.
Visit Profile →