all insights

Salesforce governor limits: a practical guide to designing around them

Governor limits aren't arbitrary hurdles — they're the price of multitenancy, and hitting one usually means your design owes something. Here's why the limits exist, which ones actually bite in production, and the patterns that keep you clear of them.

Salesforce governor limits: a practical guide to designing around them — article illustration

It’s 2 p.m. on a Tuesday. Someone in RevOps pushes 3,000 leads through Data Loader, and every batch comes back red: System.LimitException: Too many SOQL queries: 101. The code passed review. It passed its unit tests. It worked fine in the sandbox, one record at a time, for months.

The instinct is to treat this as a platform problem — Salesforce being difficult, a quota to negotiate around. That instinct is wrong, and it leads teams to the worst fixes: sprinkling @future on things, splitting loads into ever-smaller chunks, or asking Salesforce support to raise a limit that cannot be raised. In our experience, a governor limit exception is almost never the platform being stingy. It’s the platform telling you, precisely and early, where your architecture doesn’t scale.

This guide covers why the limits exist in the first place, the handful that actually cause production incidents, and the design patterns — bulkification, async offload, and caching — that make them a non-event. It ends with the more useful skill: reading limit errors as a symptom of design debt rather than a bug to patch.

Why multitenancy makes governor limits non-negotiable

Salesforce is a multitenant platform. Your org doesn’t get its own servers; it shares application servers, database resources, and CPU with thousands of other customers on the same instance. That sharing is what makes the platform economical — and it’s also why the Apex runtime strictly enforces limits so that runaway code in one org can’t monopolise resources that belong to everyone.

The enforcement is blunt by design. When your transaction crosses a limit, the governor throws a System.LimitException — an exception that cannot be caught or handled in Apex. The transaction dies and rolls back. There is no retry header, no grace period, no support ticket that raises the ceiling. A try/catch block will not save you.

This sounds harsh until you consider the alternative. On infrastructure you own, an unbounded query loop degrades your own service and you find out from your own monitoring, eventually. On shared infrastructure, it would degrade someone else’s. The governor limits are the contract that makes tenancy safe: every transaction gets a generous, predictable slice of resources, and no transaction gets more. Well-designed code — code that works in sets rather than one record at a time — rarely gets anywhere near the ceilings. That’s the tell. Limits don’t punish scale; they punish per-record thinking.

The four governor limits that actually bite in production

The Execution Governors and Limits page lists dozens of per-transaction limits, and most of them you will never hit. In practice, production incidents cluster around four. All numbers below are per transaction, verified against the official documentation as of January 2026 — check the page before relying on them, because they do change:

LimitSynchronousAsynchronous
SOQL queries100200
Records retrieved by SOQL50,00050,000
DML statements150150
Records processed by DML10,00010,000
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB
HTTP callouts100100

SOQL 101 — “Too many SOQL queries.” The most famous limit exception on the platform, and almost always the same root cause: a query inside a loop. One query per record looks harmless with one record. With a 200-record trigger batch, it’s 200 queries — double the synchronous ceiling of 100 SOQL queries per transaction. Flows fail the same way when a Get Records element sits inside a loop, and it counts against the same shared transaction budget as your Apex and your managed packages.

DML 150 — “Too many DML statements.” The mirror image: update or insert called per record instead of once per collection. A single DML statement on a list of 2,000 records counts as one statement toward the 150-statement limit; the same records updated one at a time count as 2,000. Worth knowing: publishing a platform event configured to publish after commit counts as a DML statement too, so event-heavy designs draw from the same budget.

CPU 10 seconds — “Apex CPU time limit exceeded.” The subtlest of the four, because it measures computation, not operations. Salesforce counts all Apex execution, library functions, and workflow and validation evaluation triggered by DML toward the 10,000 ms synchronous ceiling — but not database time for SOQL and DML, and not time spent waiting on callouts. The classic causes are nested loops over large collections, recursive automation (a trigger fires a flow that updates the record that fires the trigger), and CPU-hungry managed packages sharing your transaction.

Heap 6 MB — “Apex heap size too large.” Everything you hold in memory — query results, collections, strings you’re concatenating — lives on a heap capped at 6 MB synchronous, 12 MB asynchronous. This one bites code that loads 50,000 rows into a list “to be safe”, or builds giant JSON payloads in memory. The fix is usually a SOQL for loop, which iterates results in batches of 200 records instead of materialising the whole result set at once.

Notice what these four have in common. None of them limits how much data you can process. They limit how inefficiently you’re allowed to process it.

Bulkification: the fix for most limit exceptions

Bulkification is the discipline of writing code that handles 200 records as cheaply as it handles one. It’s the single highest-value habit in Apex development, and Salesforce’s own trigger best practices reduce it to two rules: minimise SOQL by querying sets of records with an IN clause, and minimise DML by collecting changes and writing them once.

The reason triggers dominate this conversation is batching. Bulk API loads and Data Loader jobs hand your trigger records in batches of up to 200 at a time, so any per-record query or write is multiplied by 200 before your logic even starts. Here’s the anti-pattern in its natural habitat:

trigger OpportunityTrigger on Opportunity (after update) {
    for (Opportunity opp : Trigger.new) {
        // One query per record: a 200-record batch = 200 queries. Dead at 101.
        List<Contact> contacts = [
            SELECT Id, Email FROM Contact WHERE AccountId = :opp.AccountId
        ];
        for (Contact c : contacts) {
            c.Description = 'Opportunity updated';
            update c;   // and one DML per contact. Dead at 151 — if we get that far.
        }
    }
}

And here’s the same logic bulkified — one query, one DML, regardless of batch size:

trigger OpportunityTrigger on Opportunity (after update) {
    Set<Id> accountIds = new Set<Id>();
    for (Opportunity opp : Trigger.new) {
        accountIds.add(opp.AccountId);
    }

    // One query for the whole batch, mapped for constant-time lookup.
    Map<Id, Account> accounts = new Map<Id, Account>([
        SELECT Id, (SELECT Id, Email FROM Contacts)
        FROM Account
        WHERE Id IN :accountIds
    ]);

    List<Contact> toUpdate = new List<Contact>();
    for (Opportunity opp : Trigger.new) {
        for (Contact c : accounts.get(opp.AccountId).Contacts) {
            c.Description = 'Opportunity updated';
            toUpdate.add(c);   // collect in memory...
        }
    }
    update toUpdate;   // ...write once.
}

(In real code this logic belongs in a handler class, not inline in the trigger — but the shape is the point: collect IDs, query once into a map, work in memory, write once.)

A few refinements matter once the basics are in place:

  • Only write what changed. Add a record to the DML list only if a field value is actually different. It cuts DML rows, and it cuts the recursion and row-lock contention that inflated writes cause downstream.
  • Guard against recursion. One trigger per object, a framework that controls execution order, and a static flag or tracked-ID set so re-entrant updates don’t burn your limits twice.
  • Bulkify your flows too. A Get Records or Update Records element inside a flow loop is the same anti-pattern in a different costume, and it fails with the same exception.
  • Instrument before you’re in trouble. The System.Limits class exposes live consumption — Limits.getQueries() against Limits.getLimitQueries() — which is useful for logging how close batch-sensitive code runs to the ceiling.

Async offload: Queueable, Batch Apex, and Platform Events

Some work genuinely doesn’t fit in a synchronous transaction, no matter how well bulkified. Recalculating rollups across a million records. Calling three external services. Anything a user shouldn’t wait on. For that, the platform gives you asynchronous Apex — with materially higher limits: 200 SOQL queries, 60 seconds of CPU, and a 12 MB heap per transaction.

The options differ more in shape than in power:

  • Queueable Apex is the default choice for “do this soon, not now”. It takes complex types as state, returns a job ID you can monitor, and supports chaining — each job can enqueue one follow-on job from an async context, which is how you process an open-ended pipeline of work without a batch job.
  • Batch Apex is for volume. Its QueryLocator can scope up to 50 million records, and — this is the part people miss — governor limits reset for each execution of the execute method. Every chunk gets a fresh budget. An org can run five batch jobs concurrently, with 100 more waiting in the flex queue.
  • Platform Events decouple entirely. Publish an event in the user’s transaction; do the heavy work in the subscriber’s transaction, which runs async with its own limits and built-in retry semantics. This is the right shape when the trigger’s job is “announce that something happened”, not “do everything that follows from it”.
  • @future methods still exist, and still solve mixed-DML errors and callouts-from-triggers, but Queueable does everything they do with monitoring and chaining on top. Treat @future as legacy in new code.

Two cautions. First, async is not an amnesty: moving an unbulkified loop into a Queueable just schedules the same failure for later, at double the CPU budget. Fix the shape of the code first, then offload what’s left. Second, async capacity is itself governed — an org gets 250,000 async executions per 24 hours, or 200 times its user licenses, whichever is greater — and jobs are queued under fair-usage rules, so “async” also means “not guaranteed to run immediately”. Design for eventual, not instant.

Platform Cache: stop paying for the same query twice

A surprising share of SOQL budget in mature orgs is spent re-reading data that almost never changes: configuration records, custom settings queried the slow way, picklist mappings, exchange rates, feature flags. Every trigger, every page load, every integration call re-queries the same rows. Platform Cache exists for exactly this — an in-memory layer that costs no SOQL to read.

It comes in two flavours. Org cache is shared across all users; session cache is scoped to one user’s session. Enterprise Edition orgs get 10 MB of cache by default, Unlimited and Performance get 30 MB, with more available for purchase. Individual cached items are capped at 100 KB, and when a partition fills up, the least recently used entries are evicted.

Salesforce’s own best-practice guidance is worth following closely here: cache a few large items rather than many small ones, and use the Cache.CacheBuilder interface so cache misses rebuild the value automatically instead of scattering null-checks through your code. The one discipline that matters is invalidation — cache is volatile, entries can be evicted at any time, and stale config is worse than slow config. Cache things that are expensive to compute and cheap to be slightly wrong about, or pair the cache with an explicit refresh when the source data changes.

Used well, caching doesn’t just claw back SOQL headroom. It removes whole classes of load from the shared database — which is the entire spirit of the limits in the first place.

Reading limit errors as a symptom of design debt

Here’s the reframe that changes how teams operate: a governor limit exception is telemetry. It’s the platform reporting, with a stack trace, the exact place where your org’s architecture stopped scaling. Treating it as a one-off bug — add a guard, shrink the batch size, move on — discards the information.

When a limit error reaches production, we find it pays to run a short structural diagnosis rather than a hot-fix:

  1. Read the cumulative limit usage in the debug log, not just the failing line. The failing query is rarely the guilty one; it’s just the 101st. Log the transaction and see what consumed the first 100.
  2. Map everything that fires on the object. Triggers, record-triggered flows, managed packages, roll-up tools — all sharing one transaction budget, in an order nobody chose. Three “fine” automations often add up to one broken transaction.
  3. Look for recursion. Trigger updates record, flow re-fires trigger. CPU timeouts and SOQL 101s in orgs with modest data volumes are usually loops, not load.
  4. Ask what the transaction is for. If a record save is also recalculating rollups, syncing two systems, and sending notifications, the problem isn’t the limit — it’s that one transaction owns five jobs. That’s an async and events conversation, not a tuning conversation.

Patterns matter more than instances. One SOQL 101 is a bug; a different limit error every month is technical debt announcing itself — overlapping automation, per-record thinking baked into old code, an org that grew by accretion. If you’re seeing the pattern, it’s worth scoring the org honestly — our org health scorecard is a reasonable place to start — and prioritising the objects where limit pressure and business criticality overlap.

Limits as a forcing function for good architecture

Strip away the Salesforce specifics and look at what the governor limits actually enforce: query in sets, don’t hold unbounded data in memory, keep synchronous work short, push slow work to the background, cache what you read repeatedly. That’s not a list of Salesforce workarounds. That’s how well-built systems behave on any platform — the difference is that on AWS the violation shows up as a surprising bill, and on Salesforce it shows up as an uncatchable exception at record 101.

Which is, once you’ve made peace with it, a gift. Most platforms let you write per-record code that works fine until the day it very much doesn’t, usually during your biggest data migration or your best sales quarter. Salesforce refuses to let that code pretend. The limits fail your design in the sandbox, loudly, while the fix is still cheap.

So the goal isn’t to memorise the numbers — they’re on one documentation page, and they shift between releases. The goal is to internalise what the numbers are protecting: shared resources, and your future self. Teams that get there stop asking “how do we get around the limit?” and start asking “what is this limit telling us about the shape of our solution?” Those teams ship code that survives the 3,000-lead Tuesday without anyone noticing. That’s the capability worth building — not limit avoidance, but architecture that never needed the ceiling in the first place.

Understanding the basics

What are governor limits in Salesforce?

Governor limits are per-transaction resource caps that the Apex runtime enforces on every Salesforce transaction — for example, 100 synchronous SOQL queries, 150 DML statements, 10 seconds of CPU time, and a 6 MB heap. Exceeding one throws a System.LimitException that cannot be caught, and the transaction rolls back. The limits exist because Salesforce is multitenant: they stop any single piece of code from monopolising infrastructure shared with other customers.

How do I fix “Too many SOQL queries: 101”?

Find the query running inside a loop — in Apex, or a Get Records element inside a flow loop — and bulkify it: collect the IDs first, run one query with an IN clause into a map, then do the per-record work in memory. Also check for recursion, where a trigger and a flow re-fire each other and burn the budget twice. The limit is hard; Salesforce support cannot raise it.

When should I use asynchronous Apex instead of synchronous?

Go async when the work doesn’t need to finish in the user’s transaction: long computations, callouts to external systems, or high-volume processing. Asynchronous contexts get roughly doubled budgets — 200 SOQL queries, 60 seconds of CPU, 12 MB of heap. Use Queueable Apex by default, Batch Apex for large record volumes (limits reset per batch chunk), and Platform Events when you want to decouple the announcement of a change from the processing that follows it.


Hitting governor limits more often than you’d like? Talk to us — turning limit errors back into architecture is what we do.

Keep reading

All insights