Loading...

Apex Bulkification: Why Code Works in Tests but Fails in Production

An Apex method that handles one record cleanly hits governor limits on the 200-record batch import three months after launch. Bulkification is the discipline that prevents this, and the test pattern that catches it before deployment.

Apex Bulkification: Why Code Works in Tests but Fails in Production

A new Apex method ships to production. The developer wrote a test, the test passed at 95 percent coverage, the code review approved, the deployment went smoothly. Three months later, a data migration brings in 50,000 new Account records. The trigger fires. The method runs. Governor limits hit. The transaction rolls back. The data migration fails. The team scrambles.

The root cause is almost always the same. The method was written assuming one record at a time. The test used one record. The code "worked." Production handed it 200 records in a single trigger invocation. The single-record assumptions broke. This is the bulkification problem, and it is the single most common cause of governor limit incidents in Salesforce orgs.

What single-record Apex looks like

A typical anti-pattern:

trigger AccountTrigger on Account (before insert) { for (Account a : Trigger.new) { // SOQL inside loop: dies at 100 records. User owner = [SELECT Id, Region__c FROM User WHERE Id = :a.OwnerId]; a.Account_Region__c = owner.Region__c; } }

This works when one record is inserted at a time (UI form submission, single API call). It fails at 101 records because Apex enforces a 100 SOQL query limit per transaction.

It also fails earlier in other ways:

  • 150 record bulk insert: 150 SOQL queries, breaches limit.
  • DML inside the same loop: 150 SOQL + 150 update queries, breaches both.
  • Related-object lookups inside the loop: multiplicative growth.

The fix is structural, not parametric. The method has to collect all the records first, query once for all the lookups it needs, build a map, then iterate using the map.

The bulkified version

The same logic, bulkified:

trigger AccountTrigger on Account (before insert) { // 1. Collect all needed lookup IDs. Set ownerIds = new Set(); for (Account a : Trigger.new) { 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 using the prefetched map. for (Account a : Trigger.new) { if (a.OwnerId != null && ownersById.containsKey(a.OwnerId)) { a.Account_Region__c = ownersById.get(a.OwnerId).Region__c; } } }

Same business logic. Zero SOQL inside the loop. One query for the entire batch. Works for 1 record, works for 200, works for 10,000 (subject to other limits).

The shape: collect IDs, query once, build map, iterate with map. Repeat for each related object the logic depends on.

Why tests miss it

Most Apex test classes look like this:

@isTest static void testAccountRegion() { Account a = new Account(Name = 'Test', OwnerId = UserInfo.getUserId()); insert a; `` Account retrieved = [SELECT Account_Region__c FROM Account WHERE Id = :a.Id]; System.assertNotEquals(null, retrieved.Account_Region__c); }

One record. The single-record code passes. The bulkified code passes. The test cannot distinguish them.

The fix is a test that genuinely exercises the batch case:

@isTest static void testAccountRegionBulk() { List accounts = new List(); for (Integer i = 0; i < 200; i++) { accounts.add(new Account(Name = 'Test ' + i, OwnerId = UserInfo.getUserId())); } insert accounts; `` // Verify all 200 got the region populated. List retrieved = [SELECT Account_Region__c FROM Account WHERE Id IN :accounts]; System.assertEquals(200, retrieved.size()); for (Account a : retrieved) { System.assertNotEquals(null, a.Account_Region__c); } }

200 records. The single-record code now hits the SOQL limit and the test fails. The bulkified code completes and the assertions pass.

This is the test that catches bulkification bugs before production. Every trigger and every Apex class method that processes record collections should have at least one 200-record test.

Sapota's bulkification checklist

The patterns that ship reliably:

No SOQL inside loops. Build the ID set first, query once, iterate with the result map.

No DML inside loops. Accumulate records into a list during iteration, do one insert listVar or update listVar at the end.

No callouts inside loops. Each Apex callout is its own governor limit. Build the request set first, dispatch in batches if needed (using Continuation or Queueable).

Aggregate by parent. When updating many child records that depend on parent state, query parents once into a map, then iterate children using the map.

200-record test as standard. Every trigger handler method tested with at least 200 records. Methods that handle larger volumes (batch processing, data migration code) tested with 1,000 or more.

No [SELECT ... FROM Y WHERE X = :singleId] patterns inside iteration. This is the SOQL-in-loop anti-pattern in its most common form. Code review should flag it.

Real-world bulkification gotchas

Five patterns that recur in audits:

The "it's only one record" excuse. A page action only ever processes one record at a time. The Apex method is single-record. Six months later, a developer adds the same method to a batch process. Single-record code in batch context dies. Write bulkified from the start; the cost is small.

Methods that look bulkified but are not. A method accepts List as input (looks bulkified) but then loops and queries inside. The signature is wrong-bulkified. Inspect what the method actually does, not just the parameter type.

Async limits forgotten. Inside @future, Queueable, or Batch context, governor limits are different (often higher). Code that works in @future fails in synchronous context because the limits dropped. Test in the actual context where the code will run.

Trigger.old confused with Trigger.new. Before-update logic that uses Trigger.new for the new state and forgets Trigger.old for the previous state. Bulkification is fine; correctness is wrong. Use Trigger.oldMap.get(record.Id) to compare with the previous state cleanly.

Maps with stale data. A method queries records, processes them, but does not re-query after a DML operation that changed them. Subsequent logic reads stale data. Either re-query or use the post-DML state from the trigger context.

What good bulkification looks like in a code review

When reviewing Apex PRs, Sapota's engineers look for:

  • No SOQL, DML, or callouts inside for loops.
  • 200-record test on every trigger handler method.
  • Maps used for related-object lookups, not individual queries.
  • Async context (@future, Queueable, Batch) tested in that context, not synchronously.
  • Aggregate updates done in single DML calls, not record-by-record.

These checks catch 90 percent of governor limit incidents before they reach production. The remaining 10 percent come from harder cases (CPU time limits on complex algorithms, heap size on large data sets) that require deeper review.

Sapota's Salesforce team holds the Platform Developer I credential and treats bulkification as a code review gating criterion on every engagement. The discipline pays off the first time the org imports a large batch of records and nothing breaks.


Auditing Apex code for bulkification or refactoring single-record code? Sapota's Salesforce team handles code review, refactoring, and test discipline 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