Putting a SOQL query inside a for loop is the most common Apex mistake, and it fails the same way every time: Salesforce allows 100 SOQL queries per transaction, so a trigger processing 150 records with one query per record throws System.LimitException: Too many SOQL queries: 101 and rolls back the entire operation. The fix is bulkification — collect all IDs before the loop, run one query outside it, store results in a Map, then look up by key inside the loop at O(1) cost.
Every SOQL statement — regardless of how many rows it returns — counts as one query against the governor limit. The limit is 100 synchronous queries per transaction. A trigger fires on batches of up to 200 records at a time. If each record triggers one query, you hit the limit after the 100th record and the entire batch fails.
The broken pattern looks like this:
// ❌ WRONG — SOQL inside loop
for (Opportunity opp : trigger.new) {
Account acc = [SELECT Id, Name, Industry
FROM Account
WHERE Id = :opp.AccountId]; // one query per record
// ... logic using acc
}
If this trigger fires on 150 opportunities in a data load, it attempts 150 queries. It fails at query 101, rolls back all 150 records, and the user sees a cryptic error.
The fix has three steps: collect IDs into a Set, query once outside the loop, store in a Map, look up inside the loop.
// ✅ CORRECT — query outside loop using Map
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : trigger.new) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId);
}
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds]
);
for (Opportunity opp : trigger.new) {
Account acc = accountMap.get(opp.AccountId);
if (acc != null) {
// use acc — zero additional queries
}
}
This uses exactly one SOQL query regardless of whether the trigger fires on 1 record or 200. The Map<Id, Account> constructor that takes a list directly converts it: keys are the record IDs, values are the records.
The same pattern applies to DML. Salesforce allows 150 DML statements per transaction. DML inside a loop hits the limit fast on bulk operations.
// ❌ WRONG — DML inside loop
for (Contact c : contactsToUpdate) {
c.Description = 'Updated';
update c; // one DML per record
}
// ✅ CORRECT — collect then DML once
List<Contact> toUpdate = new List<Contact>();
for (Contact c : contactsToUpdate) {
c.Description = 'Updated';
toUpdate.add(c);
}
update toUpdate; // one DML statement for all records
There is a construct called the "SOQL for loop" that sounds like it puts SOQL in a loop, but it is different. It processes query results in internal batches of 200 to manage heap size on large datasets.
// SOQL for loop — still ONE query, memory-efficient batching
for (List<Account> batch : [SELECT Id, Name FROM Account WHERE IsActive__c = true]) {
// batch is a List of up to 200 records
for (Account acc : batch) {
// process each record
}
} // total query count: 1, regardless of row count
Use this form when your query could return more than 50,000 records — it prevents heap overflow. The SOQL statement executes once; Salesforce paginates the results internally.
The Map<Id, SObject> constructor from a list is the most useful pattern in bulkified Apex. It also works with parent-child relationships via subqueries.
// Query parent with child subquery, build Map in one shot
Map<Id, Account> accountWithContacts = new Map<Id, Account>(
[SELECT Id, Name,
(SELECT Id, LastName, Email FROM Contacts ORDER BY LastName)
FROM Account
WHERE Id IN :accountIds]
);
for (Account acc : accountWithContacts.values()) {
for (Contact c : acc.Contacts) {
// process child records — still zero extra queries
}
}
This is a common pattern in triggers that need to process both parent and child records. The subquery counts as part of the same single SOQL statement.
Here is a complete bulkified trigger that updates a custom field on Opportunity based on the parent Account's Industry.
trigger OpportunityBulkified on Opportunity (before insert, before update) {
// Step 1: collect parent IDs
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) accountIds.add(opp.AccountId);
}
// Step 2: one query, Map result
Map<Id, Account> accMap = new Map<Id, Account>(
[SELECT Id, Industry FROM Account WHERE Id IN :accountIds]
);
// Step 3: loop through trigger records, lookup by Map
for (Opportunity opp : Trigger.new) {
Account acc = accMap.get(opp.AccountId);
if (acc != null) {
opp.Industry_Category__c = acc.Industry;
}
}
// No DML needed — before trigger modifies trigger.new in place
}
This trigger processes any batch size from 1 to 200 records using exactly one SOQL query.
The Limits class lets you check remaining governor allowances at runtime. Useful in utility methods that might be called from multiple contexts.
if (Limits.getQueries() >= Limits.getLimitQueries() - 5) {
// fewer than 5 queries remaining — skip or log warning
System.debug('Low query limit: ' + Limits.getQueries() + '/' + Limits.getLimitQueries());
}
System.debug('Queries used: ' + Limits.getQueries());
System.debug('DML used: ' + Limits.getDmlStatements());
The Limits class is also covered in detail in the article on governor limits and the 101 error.
Not every query in a loop is wrong. There are cases where it is intentional and bounded — for example, in a loop that processes 3–5 items where the bounded total can never exceed the limit. The rule is not "zero queries in loops" but "never unbounded queries in loops." If a loop processes a fixed small collection (like a hardcoded list of 3 record types), a query inside is acceptable. If a loop processes trigger.new, it is never acceptable.
trigger.new always risks hitting the 100-query limit — never do itfor (List<T> batch : [SELECT ...])) is not "SOQL in a loop" — it is one query with batched result deliveryLimits.getQueries() in shared utility methods to detect low-limit situations at runtimeEach iteration fires a separate query against the governor limit counter. Salesforce allows 100 SOQL queries per transaction. A trigger processing 200 records with one query per record would attempt 200 queries, causing a System.LimitException: Too many SOQL queries: 101 error and rolling back the entire transaction.
Bulkification means writing code that processes collections of records rather than individual records. The core pattern: collect all IDs into a Set before the loop, query all related records in one SOQL statement outside the loop, store results in a Map, then loop through the Map for O(1) lookups. This uses one query regardless of how many records are processed.
Replace the per-record query with a Map built before the loop: Map<Id, SObject> relatedMap = new Map<Id, SObject>([SELECT Id, Field FROM Object WHERE Id IN :idSet]); Then inside the loop, use relatedMap.get(record.LookupId__c) instead of querying. This collapses N queries into one.
A SOQL for loop is the syntax: for (SObject record : [SELECT ... FROM ...]) {}. It processes records in internal batches of 200, preventing heap size issues on large datasets. Use it when querying more than 50,000 records. It still counts as one SOQL query — the batching is memory management, not multiple queries.
Yes. DML inside loops causes the same pattern of errors — you get 150 DML statements per transaction. The fix is identical: collect records to insert/update/delete into a List, then call the DML operation once outside the loop on the entire List.