beginner
Fundamentals

Salesforce Governor Limits Explained With Code You Can Run

A plain-English guide to Salesforce governor limits: per-transaction caps on SOQL, DML, CPU, and heap, with runnable Apex that prints your real numbers using Limits.

7 min read
Warren Walters
apex
governor-limits
bulkification
best-practices

TL;DR

Salesforce runs every Apex transaction inside a tight budget called governor limits. Each transaction gets a fixed cap on SOQL queries, DML statements, CPU time, heap size, and more. If your code crosses a cap, the transaction throws a runtime exception and rolls back. Write bulkified code, move heavy work out of loops, and check Limits instead of guessing.

Why Governor Limits Exist

Salesforce is a multi-tenant platform. Your code shares one database with every other customer's org. Without limits, one bad script could lock the whole platform. Governor limits are the line that prevents that.

The other reason they matter: they force you to write code that works for 200 records the same way it works for one. The platform hands you 200 records on every trigger, every batch, every REST call. Code that only handles one record is buggy code waiting to break in production.

The Limits You Will Hit First

Most beginners hit the same handful of limits. Learn them now and you save yourself weeks of confused debugging.

LimitPer-transaction cap (sync)What it covers
SOQL queries100Each SELECT counts. Subqueries count too.
SOQL rows retrieved50,000Total rows returned across all queries.
DML statements150Each insert, update, delete, upsert, undelete.
DML rows10,000Total rows touched across all DML.
CPU time10,000 msReal wall-clock-ish time on the server.
Heap size6 MBMemory your script can hold.
Callouts100Outbound HTTP requests.
Future calls50@future queue.
Queueable jobs50System.enqueueJob per transaction.
Batch size200Records per execute invocation.

You can read the full list any time with Limits.getLimitMap(). Every limit exposes a getLimit(name) and a getConsumed(name) method.

Read Your Own Numbers With Limits

The cleanest debugging habit you can build is asking the platform how much budget you have left. Stop guessing, start reading.

public class GovernorBudgetPrinter {
    public static void printBudget() {
        System.debug('SOQL queries used    : ' +
            Limits.getQueries() + ' / ' + Limits.getLimitQueries());
        System.debug('SOQL rows retrieved  : ' +
            Limits.getQueryRows() + ' / ' + Limits.getLimitQueryRows());
        System.debug('DML statements used  : ' +
            Limits.getDmlStatements() + ' / ' + Limits.getLimitDmlStatements());
        System.debug('DML rows touched     : ' +
            Limits.getDmlRows() + ' / ' + Limits.getLimitDmlRows());
        System.debug('CPU time (ms)        : ' +
            Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime());
        System.debug('Heap size (bytes)    : ' +
            Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize());
    }
}

Drop that class in your org, call GovernorBudgetPrinter.printBudget() from anonymous Apex or a trigger, and you see your real numbers in the debug log. No docs, no guessing.

The Classic Mistake: SOQL In A Loop

The most common beginner bug is also the one governor limits punish the hardest. The code below looks fine. It runs once for one account and returns the right answer. Pass it 200 accounts and it dies.

public class BadAccountUpdater {
    public static void renameAccounts(List<Account> accounts) {
        for (Account acc : accounts) {
            // SOQL inside the loop. 200 accounts = 200 queries = LIMIT_EXCEEDED.
            List<Contact> contacts = [
                SELECT Id, LastName
                FROM Contact
                WHERE AccountId = :acc.Id
            ];
            acc.Description = 'Has ' + contacts.size() + ' contacts';
        }
    }
}

Run that against 200 accounts and you throw System.LimitException: Too many SOQL queries: 101. The fix is one query before the loop, then a map for the lookups.

The Fix: One Query, A Map, Bulk DML

The working version queries once, groups the results into a map, then walks the input list. 200 records cost 1 SOQL query and 1 DML update, no matter how many records you pass in.

public class GoodAccountUpdater {
    public static void renameAccounts(List<Account> accounts) {
        if (accounts == null || accounts.isEmpty()) {
            return;
        }
 
        // 1 query, regardless of how many accounts you pass in.
        List<Contact> contacts = [
            SELECT Id, AccountId
            FROM Contact
            WHERE AccountId IN :accounts
            ALL ROWS
        ];
 
        // Bucket contacts by their account so the lookup is O(1).
        Map<Id, Integer> countsByAccount = new Map<Id, Integer>();
        for (Contact c : contacts) {
            if (!countsByAccount.containsKey(c.AccountId)) {
                countsByAccount.put(c.AccountId, 0);
            }
            countsByAccount.put(c.AccountId, countsByAccount.get(c.AccountId) + 1);
        }
 
        // Update only what we need to update.
        for (Account acc : accounts) {
            Integer count = countsByAccount.get(acc.Id);
            if (count == null) {
                acc.Description = 'Has no contacts';
            } else {
                acc.Description = 'Has ' + count + ' contacts';
            }
        }
 
        // 1 DML statement, regardless of batch size.
        update accounts;
    }
}

Three things changed: one SOQL outside the loop, a Map<Id, Integer> keyed by the parent record, and one bulk DML at the end. That is the whole "bulkification" idea in one example.

DML In A Loop Is The Same Bug

Same shape, different verb. Inserting contacts one at a time burns through your 150 DML statement cap long before it burns through your data.

public class BulkContactInserter {
    public static void insertContacts(List<String> firstNames, List<String> lastNames) {
        if (firstNames == null || firstNames.isEmpty()) {
            return;
        }
 
        List<Contact> toInsert = new List<Contact>();
        for (Integer i = 0; i < firstNames.size(); i++) {
            toInsert.add(new Contact(
                FirstName = firstNames[i],
                LastName  = lastNames[i]
            ));
        }
 
        // 1 DML statement, no matter how many rows.
        insert toInsert;
    }
}

Build a list, insert once. The platform is built for that pattern.

When You Hit A Limit Anyway

Sometimes 10,000 rows really do not fit in one transaction. Salesforce ships four tools for that:

  • Batch Apex — runs execute over chunks of up to 200 records, with a fresh governor budget per chunk. Best for millions of records.
  • Queueable Apex — runs asynchronously, also with a fresh budget. Best for "I need this to run soon and I want a chain".
  • @future methods — fire-and-forget async. Lightweight but limited (no return value, no chained calls).
  • Database.Stateful batch — keeps instance variables across chunks. Use this when each chunk depends on the last.

A short queueable example, showing the same Limits query pattern from before but inside async context:

public class CountContactsQueueable implements Queueable, Database.AllowsCallouts {
    private List<Account> accounts;
 
    public CountContactsQueueable(List<Account> accounts) {
        this.accounts = accounts;
    }
 
    public void execute(QueueableContext ctx) {
        List<Contact> contacts = [
            SELECT Id, AccountId
            FROM Contact
            WHERE AccountId IN :accounts
        ];
 
        Map<Id, Integer> counts = new Map<Id, Integer>();
        for (Contact c : contacts) {
            counts.put(c.AccountId, (counts.get(c.AccountId) ?? 0) + 1);
        }
 
        for (Account acc : accounts) {
            acc.Description = 'Has ' + (counts.get(acc.Id) ?? 0) + ' contacts';
        }
        update accounts;
 
        // Useful while you are tuning: see the budget this run consumed.
        System.debug('Async SOQL: ' + Limits.getQueries() + ' / ' +
            Limits.getLimitQueries());
    }
}

Calling code: System.enqueueJob(new CountContactsQueueable(scopeList));

CPU time is the limit that catches people who already bulkified their queries. A loop with a tight string or regex inside it can chew through 10,000 ms in a few thousand iterations. The fix is the same: chunk the work, move the heavy loop into Batch Apex. Async contexts get a higher budget (60,000 ms of CPU and 200 SOQL queries), but they are not infinite, and the moment your queueable chains into another one the cap stays the same. If you are debugging a slow job, log Limits.getCpuTime() at the start and end of execute — the delta is how much your code actually cost, which is rarely how long it felt.

Why Async Limits Are Different

Synchronous Apex caps you at 100 SOQL queries and 10 seconds of CPU. Async Apex (Batch, Queueable, @future, Schedulable) doubles the SOQL cap to 200 and raises CPU to 60 seconds. Heap also jumps from 6 MB to 12 MB. The tradeoff is latency: an async job runs when the platform has space, which might be now or might be in an hour.

The classic trap is treating async like a magic "more limit" button. It gives you headroom but it does not give you permission to write bad code. A queueable that fires 50 more queueables in one transaction still has the 50-callouts cap, the 12 MB heap, and the CPU budget. Read the same Limits numbers you would read in sync code, just with different caps.

The Habit That Prevents Most Of This

Three rules cover 90 percent of "why did my code blow up":

  1. One SOQL outside the loop, keyed by IN. Same fix for DML.
  2. Use Limits.getX() and Limits.getLimitX() in debug logs. Read the numbers, do not guess.
  3. When in doubt, write a test that inserts 200 records. Triggers and batch classes run with 200 records in production. If your test does not reflect that, your test is lying to you.

A test that hits 200 records per trigger invocation is the only test that proves you are inside the budget.

Where To Practice Next

Start with the governor limits query counter challenge to see Limits.getQueries() in action inside a real assertion. Then jump to bulk insert contacts for the other half of the same idea, and follow up with the bulk update account industry challenge to wire both together. If you are studying for PD1, the whole apex fundamentals path walks through these patterns one at a time.

WW

About Warren Walters

Salesforce MVP and transformative mentor with 8+ years in the Salesforce realm. Founder of Lightning Challenge, dedicated to nurturing the next generation of Salesforce talent through hands-on practice and real-world coding challenges.

Visit Profile →
Share:

Related Posts