Governor limits in bulk trigger design: patterns that actually scale

Written by

in

Most Apex trigger bugs aren’t logic bugs. They’re volume bugs. Code that works fine in a dev org against three test records falls over the first time someone imports 400 leads or a nightly integration batches 2,000 case updates in one go. By then it’s live, and the failure looks like a mystery instead of what it actually is: a trigger that was never built to run more than once per transaction.

I’ve reviewed enough trigger code across enterprise orgs to know the failures repeat. Same handful of anti-patterns, different object, different team. Here’s what actually holds up under volume, and where the line sits between “fix the trigger” and “this needs to be async.”

Why governor limits catch people off guard

Salesforce doesn’t limit how many records you can touch. It limits how many times you can call certain operations within a single transaction: 100 SOQL queries, 150 DML statements, 10,000 DML rows, 10 seconds of CPU time. A trigger that fires once per record, not once per batch, blows through those limits the moment more than a handful of records land in the same transaction.

The trap is that single-record testing hides the problem completely. You insert one lead, the trigger fires, one SOQL query runs, everything’s fine. Nobody sees the SOQL-inside-a-for-loop until a data load or an API integration sends 200 records through at once and the transaction dies mid-way with a “Too many SOQL queries: 101” error that tells you almost nothing about which line caused it.

The pattern that fixes 90% of it: bulkify first, always

Bulkification isn’t an optimisation you add later. It’s the baseline shape of correct trigger code. The rule is simple: never put a query or a DML statement inside a loop over trigger records.

Collect, then query once

Instead of querying related records for each item in Trigger.new, pull every relevant ID into a Set<Id> first, run one SOQL query against the whole set, and build a map keyed by the field you need to match on. Every lookup after that is a map read, not a query. This one change is usually the difference between a trigger that handles 5 records and one that handles 5,000.

Batch the DML the same way

Same logic applies on the way out. Build a List of records to update or insert as you process the trigger set, and fire one DML statement at the end. Not one per record, and not one per sub-loop either. If you’re updating related records based on the trigger object, group them into a single list before the DML call, even if that means restructuring the loop to build up state first and act second.

Use maps instead of nested loops

Nested for-loops over related record lists look harmless until the data volume climbs. A loop inside a loop is O(n²), and at a few hundred records that’s suddenly tens of thousands of iterations for logic that should have been a single map lookup. If you find yourself looping through child records for every parent record, stop and build a Map<Id, List<SObject>> instead, keyed by the parent ID, populated in one pass.

The pattern people skip: recursion control

Bulkification gets the attention. Recursion is the one that actually causes production incidents. Code review rarely catches it, and the failure usually shows up months later, when someone adds a second trigger action that happens to update the same object.

If your trigger updates the record it’s firing on, or updates a related object whose trigger updates back, you can end up re-entering the same trigger multiple times in one transaction without meaning to. The standard fix is a static boolean flag, set to true the first time the trigger body runs and checked at the top of every subsequent entry, so the second and third passes short-circuit. A cleaner version of the same idea uses a static Set<Id> of record IDs already processed rather than a blunt on/off switch, which matters when the trigger legitimately needs to fire again for records it hasn’t seen yet in the same transaction.

Either way, don’t assume “it worked in the sandbox test” rules this out. Recursion usually needs a specific chain of updates to trigger, and that chain often doesn’t exist until someone builds a second automation six months after the first one shipped.

One trigger, one handler, per object

This is more of an architecture decision than a governor-limit fix directly, but it prevents the two problems above from compounding. Multiple triggers on the same object, each written independently, means multiple separate paths that can each query or DML inside a loop, and no single place to coordinate recursion control or execution order. A trigger handler pattern, one trigger per object that delegates to a handler class with clearly separated before/after insert/update/delete methods, gives you one place to enforce bulkification and one place to manage state across the whole transaction. It also means a new developer can find the logic instead of hunting through however many trigger files have accumulated on a Case object over the years.

When bulkification isn’t enough

Sometimes the honest answer isn’t a better trigger. It’s moving the work out of the trigger context entirely. If a trigger needs to make a callout, that can’t happen synchronously at all, callouts from triggers have to go through a Queueable or a future method. If the processing genuinely needs to touch tens of thousands of records, batch Apex with its own governor limit reset per chunk is the right tool, not a trigger straining to stay under 10,000 DML rows in one shot.

A good rule of thumb: if you’re bulkifying correctly and still hitting limits on realistic data volumes, that’s a signal the logic belongs in an async context, not a sign you need a cleverer synchronous workaround.

Test at the volume that actually breaks things

A test class that inserts one record and asserts the trigger fired proves almost nothing about whether the trigger scales. Write at least one test method per trigger that inserts 200 records (the standard batch size Salesforce uses for bulk operations) and confirms it completes without hitting limits. That test will catch the SOQL-in-a-loop mistake in about the time it takes to run, long before it reaches a client’s production org during a data migration or an integration spike.

None of this is exotic. Bulkify the queries, bulkify the DML, control recursion, keep one handler per object, and push genuinely large or callout-dependent work into async processing. The teams that get burned aren’t missing knowledge of these patterns. They’re skipping them under deadline pressure on the trigger that “only handles a couple of records anyway”, right up until it doesn’t.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *