Loading...

SOQL Inside Loops: The Single Most Common Governor Limit Cause

Apex limits 100 SOQL queries per synchronous transaction. The 101st query throws and rolls back the whole operation. Almost every Salesforce org has at least one trigger that hits this. The refactor is mechanical and the test that catches it is short.

SOQL Inside Loops: The Single Most Common Governor Limit Cause

Apex enforces a hard limit of 100 SOQL queries per synchronous transaction. The 101st query throws System.LimitException: Too many SOQL queries: 101 and the whole transaction rolls back. Every Salesforce org of any age has at least one trigger or class that hits this limit when the input volume crosses the threshold. The pattern is so common that "SOQL inside loops" is the first thing any senior engineer checks when triaging a governor limit incident.

The fix is mechanical. The test that catches it before production is short. The discipline of catching it during code review is what separates orgs that fight this monthly from orgs that ship clean.

The anti-pattern

Every form of the bug looks similar:

for (Account a : accounts) { User owner = [SELECT Id, Region__c FROM User WHERE Id = :a.OwnerId]; // ... use owner.Region__c ... }

Or inside trigger handlers:

for (Opportunity o : Trigger.new) { List lines = [ SELECT Id, Quantity FROM OpportunityLineItem WHERE OpportunityId = :o.Id ]; // ... process lines ... }

Or buried in service methods that look bulkified at the entry point:

public void processAccounts(List accounts) { for (Account a : accounts) { // Looks bulkified because the input is a List. // But the body does a SOQL per record. Contact primary = [SELECT Id, Email FROM Contact WHERE AccountId = :a.Id AND Primary__c = TRUE LIMIT 1]; // ... } }

All three breach the 100-query limit at 101 records. All three are written by developers who learned Apex on single-record examples.

The fix shape

Every refactor follows the same structure:

  1. Collect the IDs needed for lookups before the loop.
  2. Issue one SOQL query that retrieves all the related records.
  3. Build a map keyed by the lookup ID.
  4. Iterate using the prefetched map.

Applied to the first example:

// 1. Collect lookup IDs. Set ownerIds = new Set(); for (Account a : accounts) { if (a.OwnerId != null) ownerIds.add(a.OwnerId); } `// 2. Query once.` `Map ownersById = new Map(` ` [SELECT Id, Region__c FROM User WHERE Id IN :ownerIds]` `);` // 3. Iterate with the map. for (Account a : accounts) { if (ownersById.containsKey(a.OwnerId)) { User owner = ownersById.get(a.OwnerId); // ... use owner.Region__c ... } }

Applied to the second example (one-to-many relationship), the pattern shifts to a multi-value map:

Set oppIds = new Set(); for (Opportunity o : Trigger.new) oppIds.add(o.Id); `// Query all lines for all opps in one go.` `Map> linesByOpp =` ` new Map>();` `for (OpportunityLineItem li :` ` [SELECT Id, OpportunityId, Quantity` ` FROM OpportunityLineItem` ` WHERE OpportunityId IN :oppIds]) {` ` if (!linesByOpp.containsKey(li.OpportunityId)) {` ` linesByOpp.put(li.OpportunityId, new List());` ` }` ` linesByOpp.get(li.OpportunityId).add(li);` `}` // Now iterate with the map. for (Opportunity o : Trigger.new) { List lines = linesByOpp.containsKey(o.Id) ? linesByOpp.get(o.Id) : new List(); // ... process lines ... }

The shape extends naturally. Two related-object lookups become two prefetch queries plus two maps. The total query count is bounded by the number of related objects, not by the input record count.

The test that catches it

Single-record tests cannot detect SOQL-in-loop bugs. The test that does:

@isTest static void testAccountOwnerRegion_bulk() { // Create 200 accounts. This is the trigger batch size. List accounts = new List(); for (Integer i = 0; i < 200; i++) { accounts.add(new Account( Name = 'Test ' + i, OwnerId = UserInfo.getUserId() )); } ` Test.startTest();` ` insert accounts;` ` Test.stopTest();` // Assert all 200 got processed. List result = [ SELECT Id, Account_Region__c FROM Account WHERE Id IN :accounts ]; System.assertEquals(200, result.size()); for (Account a : result) { System.assertNotEquals(null, a.Account_Region__c); } }

200 records is the trigger batch size. If the SOQL-in-loop bug exists, this test throws Too many SOQL queries: 101 and fails. If the code is bulkified, it passes. The test is the gate.

Every trigger handler should have at least one 200-record test. Methods handling larger data sets need 1,000 or 5,000-record tests run in the appropriate context (often using @isTest(SeeAllData=false) plus deliberate test data setup).

How SOQL-in-loop sneaks in

Five common ways the bug enters production:

1. The "single-record entry point" justification. A method is called from a screen that only processes one record at a time. The developer concludes single-record code is fine. Then later, the same method gets called from a trigger or batch context. Bug emerges.

Lesson: assume every method might be called in bulk. Bulkify proactively.

2. Refactoring without bulkification. A working bulkified method has logic extracted into a helper. The helper takes a single record parameter and does its own SOQL. Now the helper is called from inside a loop. The refactor introduced the bug.

Lesson: when extracting helpers, preserve the bulk contract. Pass collections, not single records.

3. Recursive queries via getters. An Apex class has a getter that lazy-loads related data via SOQL. The getter is called inside a loop. Each call fires a query.

Lesson: avoid SOQL inside getters of objects that get iterated. Prefer eager loading at the class entry point.

4. Conditional SOQL inside loops. The query is inside an if block that "usually" does not fire. Tests miss it because the condition rarely matches in test setup. Production data hits the condition routinely.

Lesson: test the path where the conditional fires. Or move the conditional logic above the loop and prefetch unconditionally.

5. Trigger handler delegation that hides the bug. The main trigger handler is bulkified. It delegates to a service class that processes records one at a time and does its own SOQL per record. The trigger looks clean; the service has the bug.

Lesson: code review service classes called by triggers as carefully as the trigger itself.

Code review checklist

When reviewing a Salesforce PR, Sapota's engineers grep for these patterns:

  • for.*{ followed within a few lines by [SELECT or Database.query.
  • Any Apex class method with a Record sObject parameter that does its own SOQL inside.
  • @future or Queueable methods that loop over related records and query each.
  • LWC @AuraEnabled methods that get called in bulk from the client and query per record.
  • Schedulable Apex classes whose execute method iterates and queries.

Each of these is a candidate. Inspect, refactor if confirmed.

What good SOQL discipline looks like

A Salesforce org with healthy SOQL practices:

  • Zero SOQL inside for loops across the entire codebase (verifiable via static analysis).
  • Every trigger handler tested with a 200-record bulk test.
  • Service methods accept collections, not single records.
  • Related-object lookups pre-loaded into maps at entry points.
  • Code review specifically checks for SOQL-in-loop on every PR.

Sapota's Salesforce team holds the Platform Developer I credential and treats SOQL-in-loop as a code review blocker on every engagement. The pattern is so common, and the fix is so mechanical, that the discipline pays back the first time the org imports a large batch and nothing crashes.


Debugging governor limit incidents or auditing Apex code for SOQL-in-loop? Sapota's Salesforce team handles code review, refactoring, and bulkification audits on production engagements. Get in touch ->

See our full platform services for the stack we cover.

Contact Us Now

Share Your Story

We build trust by delivering what we promise – the first time and every time!

We'd love to hear your vision. Our IT experts will reach out to you during business hours to discuss making it happen.

WHY CHOOSE US

"Collaborate, Elevate, Celebrate where Associates - Create Project Excellence"

SapotaCorp beyond the IT industry standard, we are

  • Certificated
  • Assured quality
  • Extra maintenance

Tell us about your project

close