The short answer: bulkification is three shapes. Query once into a Map, build a collection and do one DML at the end, and decide deliberately whether a failure should take the whole batch down. Everything else is detail. Code written in those shapes works at 1 record and at 200 without changing.
Why 200 is the number
Triggers fire in chunks of up to 200 records. The Bulk API, Data Loader, a flow acting on a collection and a batch execute all deliver work in bulk. Your limits are 100 SOQL queries, 150 DML statements, 50,000 query rows and 10,000 DML rows per synchronous transaction. One query inside a loop over 200 records uses half your query budget in a single line.
The arithmetic is brutal and simple. Per-record work multiplies. Per-transaction work does not.
Shape one: query into a Map
Collect the keys first, query once, then look up.
Set<Id> accountIds = new Set<Id>();
for (Opportunity o : Trigger.new) {
if (o.AccountId != null) { accountIds.add(o.AccountId); }
}
Map<Id, Account> accounts = new Map<Id, Account>([
SELECT Id, Industry, OwnerId FROM Account WHERE Id IN :accountIds
]);
for (Opportunity o : Trigger.new) {
Account a = accounts.get(o.AccountId);
if (a != null) { o.Industry__c = a.Industry; }
}
Two loops and one query, regardless of input size. The Map<Id, SObject> constructor taking a query directly is the idiomatic form and worth knowing.
Shape two: collect, then DML once
Never call update inside a loop. Build the list and commit at the end.
List<Case> toUpdate = new List<Case>();
for (Case c : cases) {
if (needsEscalation(c)) {
c.Priority = 'High';
toUpdate.add(c);
}
}
if (!toUpdate.isEmpty()) { update toUpdate; }
The isEmpty() check is not decoration. An empty DML still consumes one of your 150 statements, and in a trigger that runs on every save that adds up.
When you are updating records of several types, keep one list per object and do one DML each. When the same record could be touched twice by different pieces of logic, use a Map<Id, SObject> as the accumulator instead of a list, so the second touch modifies the same instance rather than creating a duplicate row in the DML.
Shape three: partial success
By default DML is all or nothing. One bad record rolls back the other 199. Sometimes that is right, and sometimes it means a single malformed row from an integration blocks a whole night’s load.
Database.SaveResult[] results = Database.update(toUpdate, false);
List<String> failures = new List<String>();
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
failures.add(toUpdate[i].Id + ': ' +
results[i].getErrors()[0].getMessage());
}
}
if (!failures.isEmpty()) { ErrorLog.record(failures); }
Two things people get wrong here. The results array is positionally aligned with the input list, so you need the index to know which record failed. And a partial-success DML that logs nothing is worse than a hard failure, because the data is now silently inconsistent and nobody knows.
Pick deliberately. All-or-nothing for anything that must stay consistent. Partial success for bulk loads where you want the good rows in and a report of the bad ones.
The things that break at 200
- A nested loop over two collections. 200 by 200 is 40,000 iterations, which will show up as CPU rather than as a limit exception. Build a Map and do one pass.
- A describe call per record. Cache it in a static.
- Any callout in a loop. You have 100 callouts and a 120 second cumulative ceiling.
Trigger.new[0]. This is the tell that a developer tested with one record and never thought about the rest.- Sending email inside a loop. 10 email invocations per transaction, so the eleventh record fails.
Aggregates are the cheap escape
When you need a count or a sum across many child records, an aggregate query costs query rows equal to the number of groups, not the number of underlying records. Summing 800,000 line items grouped by opportunity consumes rows equal to the opportunity count. You get 300 aggregate queries per transaction, which is plenty.
Frequently asked
Does a Map lookup cost a governor limit? No. It costs CPU, and negligibly. Trading queries for map lookups is almost always the right trade.
Is 200 always the chunk size? It is the maximum for a trigger. Smaller chunks happen, and batch Apex lets you set the scope yourself. Write for the maximum.
What about DML rows? 10,000 per transaction. A trigger updating five related records per input record hits it at 2,000 input rows, which a batch job will reach.
