← All articles

Apex · Beginner · For developers · 3 min read

Designing Apex around governor limits

Code that works on one record in a sandbox can fail on two hundred in production. The limits that matter, and the habits that keep you inside them.

Salesforce is multitenant: your code shares database and application servers with every other customer on the same instance. Governor limits are how the platform stops one tenant's runaway loop from slowing down everyone else. They are enforced per transaction, they are not negotiable, and exceeding one throws a System.LimitException that cannot be caught — the whole transaction rolls back.

That makes limits a design input, not a production surprise. The good news is that a handful of habits cover almost every case.

The numbers worth remembering

The full list is long, but these are the limits that real code actually runs into. Asynchronous Apex (Queueable, Batch, future methods) gets more headroom on some of them.

Per transactionSynchronousAsynchronous
SOQL queries100200
Records retrieved by SOQL50,00050,000
DML statements150150
Records processed by DML10,00010,000
CPU time10 s60 s
Heap size10 MB25 MB
Callouts100100

One more number belongs next to that table: a trigger is handed up to 200 records at a time. A user saving one record in the UI gives your trigger a list of one. A data load, an integration or a bulk update gives it two hundred. The same code runs in both cases.

No queries or DML inside a loop

This is the classic. The trigger below is correct for a single record:

trigger OpportunityTrigger on Opportunity (before insert) {
    for (Opportunity opp : Trigger.new) {
        Account acc = [SELECT Industry FROM Account WHERE Id = :opp.AccountId];
        opp.Description = 'Industry: ' + acc.Industry;
    }
}

Load 200 opportunities and it runs 200 queries. The 101st throws Too many SOQL queries: 101 and nothing is saved. The fix is always the same shape: collect the keys, query once, look results up from a map.

trigger OpportunityTrigger on Opportunity (before insert) {
    Set<Id> accountIds = new Set<Id>();
    for (Opportunity opp : Trigger.new) {
        if (opp.AccountId != null) {
            accountIds.add(opp.AccountId);
        }
    }

    Map<Id, Account> accounts = new Map<Id, Account>(
        [SELECT Industry FROM Account WHERE Id IN :accountIds]
    );

    for (Opportunity opp : Trigger.new) {
        Account acc = accounts.get(opp.AccountId);
        if (acc != null) {
            opp.Description = 'Industry: ' + acc.Industry;
        }
    }
}

One query, whether the trigger receives one record or two hundred. DML follows the same rule: build a list inside the loop, then issue a single insert or update on the list after it.

Remember that the transaction is shared

Limits are counted across everything that runs in the transaction, not per class. Your trigger, the record-triggered flows on the same object and any triggers that fire because of your DML all draw from the same 100 queries. A trigger that uses 30 of them looks fine in isolation and fails once three other automations are stacked on the same save.

Two practices help. Keep one trigger per object that delegates to a handler class, so the order of work is visible in one place. And query once per transaction, not once per method — pass the data down instead of letting each helper fetch its own copy.

Move heavy work out of the request

If a job genuinely needs to touch tens of thousands of records, no amount of bulkification will fit it into one synchronous transaction. That is what asynchronous Apex is for:

  • Queueable for a follow-up unit of work that should not hold up the user: it runs in its own transaction with the higher asynchronous limits.
  • Batch Apex for large data volumes. The job is split into chunks (200 records by default), and every chunk gets a fresh set of limits.
  • Scheduled Apex for work that belongs to a time of day rather than to a user action.

Asynchronous does not mean unlimited. The same rules apply inside each chunk; you have just chosen how big a chunk is.

Measure instead of guessing

The Limits class reports consumption at any point in a transaction, which is useful both in debug logs and in guard clauses:

System.debug('SOQL: ' + Limits.getQueries() + ' of ' + Limits.getLimitQueries());
System.debug('CPU:  ' + Limits.getCpuTime() + ' of ' + Limits.getLimitCpuTime() + ' ms');

The debug log also prints a cumulative limit summary at the end of every transaction. Reading it for the busiest objects in an org takes minutes and shows how much headroom is left before anything breaks.

Test with 200 records, not one

A test that inserts a single record proves the logic. It says nothing about limits. For every trigger, add a test that inserts a full batch:

@IsTest
static void handlesFullTriggerBatch() {
    Account acc = new Account(Name = 'Bulk test', Industry = 'Energy');
    insert acc;

    List<Opportunity> opps = new List<Opportunity>();
    for (Integer i = 0; i < 200; i++) {
        opps.add(new Opportunity(
            Name = 'Opp ' + i,
            AccountId = acc.Id,
            StageName = 'Prospecting',
            CloseDate = Date.today().addDays(30)
        ));
    }

    Test.startTest();
    insert opps;
    Test.stopTest();

    List<Opportunity> saved = [SELECT Description FROM Opportunity WHERE AccountId = :acc.Id];
    Assert.areEqual(200, saved.size());
    for (Opportunity opp : saved) {
        Assert.areEqual('Industry: Energy', opp.Description);
    }
}

Test.startTest() resets the limit counters, so the code under test is measured on its own rather than together with the data setup. If the trigger has a query in a loop, this test fails — in a sandbox, before a data load finds it for you.

In short

  • Assume every entry point receives 200 records.
  • Collect, query once, look up from a map; build a list, write once.
  • Budget limits for the whole transaction, not for your class alone.
  • Send large or slow work to Queueable or Batch Apex.
  • Keep a 200-record test next to every trigger.

Hitting limits in your org?

Tell us what you are building on Salesforce and we will get back to you. The first call is free, and you will get a straight answer on whether we are the right team for the job.

contactus@trailteam.dev