Designing Apex around governor limits
Code that works on one record in a sandbox can fail on two hundred in production. The limits that matter, and the habits that keep you inside them.
Salesforce is multitenant: your code shares database and application servers with every
other customer on the same instance. Governor limits are how the platform stops one
tenant's runaway loop from slowing down everyone else. They are enforced per transaction,
they are not negotiable, and exceeding one throws a System.LimitException
that cannot be caught — the whole transaction rolls back.
That makes limits a design input, not a production surprise. The good news is that a handful of habits cover almost every case.
The numbers worth remembering
The full list is long, but these are the limits that real code actually runs into. Asynchronous Apex (Queueable, Batch, future methods) gets more headroom on some of them.
| Per transaction | Synchronous | Asynchronous |
|---|---|---|
| SOQL queries | 100 | 200 |
| Records retrieved by SOQL | 50,000 | 50,000 |
| DML statements | 150 | 150 |
| Records processed by DML | 10,000 | 10,000 |
| CPU time | 10 s | 60 s |
| Heap size | 10 MB | 25 MB |
| Callouts | 100 | 100 |
One more number belongs next to that table: a trigger is handed up to 200 records at a time. A user saving one record in the UI gives your trigger a list of one. A data load, an integration or a bulk update gives it two hundred. The same code runs in both cases.
No queries or DML inside a loop
This is the classic. The trigger below is correct for a single record:
trigger OpportunityTrigger on Opportunity (before insert) {
for (Opportunity opp : Trigger.new) {
Account acc = [SELECT Industry FROM Account WHERE Id = :opp.AccountId];
opp.Description = 'Industry: ' + acc.Industry;
}
}
Load 200 opportunities and it runs 200 queries. The 101st throws
Too many SOQL queries: 101 and nothing is saved. The fix is always the same
shape: collect the keys, query once, look results up from a map.
trigger OpportunityTrigger on Opportunity (before insert) {
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId);
}
}
Map<Id, Account> accounts = new Map<Id, Account>(
[SELECT Industry FROM Account WHERE Id IN :accountIds]
);
for (Opportunity opp : Trigger.new) {
Account acc = accounts.get(opp.AccountId);
if (acc != null) {
opp.Description = 'Industry: ' + acc.Industry;
}
}
}
One query, whether the trigger receives one record or two hundred. DML follows the same
rule: build a list inside the loop, then issue a single insert or
update on the list after it.
Remember that the transaction is shared
Limits are counted across everything that runs in the transaction, not per class. Your trigger, the record-triggered flows on the same object and any triggers that fire because of your DML all draw from the same 100 queries. A trigger that uses 30 of them looks fine in isolation and fails once three other automations are stacked on the same save.
Two practices help. Keep one trigger per object that delegates to a handler class, so the order of work is visible in one place. And query once per transaction, not once per method — pass the data down instead of letting each helper fetch its own copy.
Move heavy work out of the request
If a job genuinely needs to touch tens of thousands of records, no amount of bulkification will fit it into one synchronous transaction. That is what asynchronous Apex is for:
- Queueable for a follow-up unit of work that should not hold up the user: it runs in its own transaction with the higher asynchronous limits.
- Batch Apex for large data volumes. The job is split into chunks (200 records by default), and every chunk gets a fresh set of limits.
- Scheduled Apex for work that belongs to a time of day rather than to a user action.
Asynchronous does not mean unlimited. The same rules apply inside each chunk; you have just chosen how big a chunk is.
Measure instead of guessing
The Limits class reports consumption at any point in a transaction, which is
useful both in debug logs and in guard clauses:
System.debug('SOQL: ' + Limits.getQueries() + ' of ' + Limits.getLimitQueries());
System.debug('CPU: ' + Limits.getCpuTime() + ' of ' + Limits.getLimitCpuTime() + ' ms');
The debug log also prints a cumulative limit summary at the end of every transaction. Reading it for the busiest objects in an org takes minutes and shows how much headroom is left before anything breaks.
Test with 200 records, not one
A test that inserts a single record proves the logic. It says nothing about limits. For every trigger, add a test that inserts a full batch:
@IsTest
static void handlesFullTriggerBatch() {
Account acc = new Account(Name = 'Bulk test', Industry = 'Energy');
insert acc;
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 200; i++) {
opps.add(new Opportunity(
Name = 'Opp ' + i,
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
));
}
Test.startTest();
insert opps;
Test.stopTest();
List<Opportunity> saved = [SELECT Description FROM Opportunity WHERE AccountId = :acc.Id];
Assert.areEqual(200, saved.size());
for (Opportunity opp : saved) {
Assert.areEqual('Industry: Energy', opp.Description);
}
}
Test.startTest() resets the limit counters, so the code under test is
measured on its own rather than together with the data setup. If the trigger has a query
in a loop, this test fails — in a sandbox, before a data load finds it for you.
In short
- Assume every entry point receives 200 records.
- Collect, query once, look up from a map; build a list, write once.
- Budget limits for the whole transaction, not for your class alone.
- Send large or slow work to Queueable or Batch Apex.
- Keep a 200-record test next to every trigger.