SapotaCorp

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.

Key takeaways

  • Apex governor limit allows 100 SOQL queries per synchronous transaction. The 101st query throws LimitException and rolls back the entire operation. Almost every Salesforce org has at least one trigger that hits this at production scale, even though tests pass at small batch sizes.
  • SOQL inside loops is the single most common governor limit cause. 100 records produce 100 queries — exactly the limit; add 1 more record and the transaction fails. Production data loads with 200-record chunks trigger the failure on every chunk.
  • The refactor is mechanical: pull the SOQL out of the loop, query the related records once with an IN clause, build a Map keyed by ID, look up inside the loop instead of querying. The change reduces 100 queries to 1 query and keeps the same business logic.
  • Code review catches this faster than testing does. The pattern is visually obvious in Apex source (SELECT inside for(...)); reviewers can spot it in seconds. Mandate PR-level review for every Apex trigger and method that processes record collections.
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<OpportunityLineItem> 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<Account> 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<Id> ownerIds = new Set<Id>();
for (Account a : accounts) {
  if (a.OwnerId != null) ownerIds.add(a.OwnerId);
}

// 2. Query once.
Map<Id, User> ownersById = new Map<Id, User>(
  [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<Id> oppIds = new Set<Id>();
for (Opportunity o : Trigger.new) oppIds.add(o.Id);

// Query all lines for all opps in one go.
Map<Id, List<OpportunityLineItem>> linesByOpp =
  new Map<Id, List<OpportunityLineItem>>();
for (OpportunityLineItem li :
     [SELECT Id, OpportunityId, Quantity
      FROM OpportunityLineItem
      WHERE OpportunityId IN :oppIds]) {
  if (!linesByOpp.containsKey(li.OpportunityId)) {
    linesByOpp.put(li.OpportunityId, new List<OpportunityLineItem>());
  }
  linesByOpp.get(li.OpportunityId).add(li);
}

// Now iterate with the map.
for (Opportunity o : Trigger.new) {
  List<OpportunityLineItem> lines = linesByOpp.containsKey(o.Id)
    ? linesByOpp.get(o.Id)
    : new List<OpportunityLineItem>();
  // ... 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<Account> accounts = new List<Account>();
  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<Account> 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.