Salesforce Governor Limits & Performance: Complete Guide (2026)

If you’ve spent any time writing Apex code, you’ve almost certainly run face-first into a System.LimitException. Maybe it was during a data migration. Maybe it was a trigger that worked perfectly in dev but exploded in production. Either way, Salesforce governor limits aren’t just a nuisance — they’re the rules of the game.

This guide breaks down every major governor limit you’ll encounter, explains why they exist, and gives you concrete strategies to write code that stays well within bounds. Whether you’re a developer writing your first batch job or an architect reviewing a complex integration, this is the reference you’ll want bookmarked.


Table of Contents

  1. Understanding Multi-tenant Architecture
  2. Types of Governor Limits
  3. SOQL Query Limits
  4. DML Statement Limits
  5. CPU Time Limits
  6. Heap Size Limits
  7. Callout Limits
  8. Using the Limits Class
  9. Bulkification Techniques
  10. Query Optimization
  11. Asynchronous Processing for Large Data
  12. Monitoring and Debugging
  13. #FAQFAQ
  14. Quick Reference Card

Understanding Multi-tenant Architecture

Why Governor Limits Exist

Salesforce runs on a shared, multi-tenant architecture — meaning thousands of organizations share the same underlying infrastructure. Unlike a dedicated server where your app can consume all available CPU and memory, every Salesforce org is a tenant in a massive apartment building. If one tenant blasts music at 3 AM, everyone suffers.

Governor limits are Salesforce’s way of being a good landlord. They ensure that no single Apex transaction can monopolize resources and degrade performance for other orgs running on the same platform. In that sense, they’re not a flaw in the platform — they’re a feature of it.

Here’s what that means practically: every Apex transaction runs inside a “governor bubble.” The moment your code starts executing — whether triggered by a UI action, a scheduled job, or an API call — Salesforce starts tracking your resource consumption. Cross a limit, and you get a hard exception. No warnings, no retries, just a clean crash.

Once you internalize that model, limits stop feeling arbitrary. They’re the contract you accept when you build on a shared platform, and working with them (rather than against them) is what separates great Salesforce developers from frustrated ones.

Multi-Tenant Architecture in Salesforce
Multi-Tenant Architecture in Salesforce

Salesforce documentation on Governor limits here.


Types of Governor Limits

Synchronous vs. Asynchronous Limits

Not all governor limits are created equal. Salesforce applies different thresholds depending on how your code is running.

Synchronous limits apply to code triggered directly by a user action — a button click, a trigger firing on save, a page load. These have the tightest constraints because the user is waiting for a response.

Asynchronous limits apply to background processes: Batch Apex, Queueable jobs, Future methods, and Scheduled Apex. Since no one’s sitting there waiting, Salesforce gives you more breathing room.

Here’s a quick comparison:

LimitSynchronousAsynchronous
SOQL Queries100200
DML Statements150150
CPU Time10,000 ms60,000 ms
Heap Size6 MB12 MB
Callouts100100 (Future only)
Query Rows Returned50,00050,000
Future Method Calls50N/A

The pattern here is clear: when user experience is on the line, limits are tighter. When you’re running in the background, Salesforce extends trust. Use that to your advantage when designing large-scale data operations.


SOQL Query Limits

100 SOQL Queries Per Transaction

The most commonly hit limit for new Apex developers is the 100 SOQL queries per transaction cap. It sounds like a lot — until your trigger fires on a 200-record bulk upload and you’re making a query inside a for loop.

The classic offender looks like this:

Apex Code

// ❌ WRONG — queries in loops will blow up in bulk scenarios
for (Account acc : trigger.new) {
    List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
    // process contacts...
}

If trigger.new contains 150 accounts, that’s 150 SOQL queries — 50 over the limit. This pattern works perfectly in a single-record test. It fails catastrophically in production.

The governor limit doesn’t care how many rows each query returns — it counts the number of query statements executed. So twenty queries returning one row each hits the counter twenty times.

Worth knowing: SOQL queries issued from managed packages count toward the same limit as your custom code. If you have packages installed, you’re sharing the 100-query budget with them. This catches a lot of architects off guard during integration projects.

Query Optimization Strategies

Getting out of the 100-query trap requires rethinking when you query, not just how. Here are the core strategies:

1. Bulk-collect before your loop

Apex Code

// ✅ CORRECT — one query outside the loop
Set<Id> accountIds = new Set<Id>();
for (Account acc : trigger.new) {
    accountIds.add(acc.Id);
}

Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
    if (!contactsByAccount.containsKey(c.AccountId)) {
        contactsByAccount.put(c.AccountId, new List<Contact>());
    }
    contactsByAccount.get(c.AccountId).add(c);
}

One query instead of N. This pattern scales to any number of records.

2. Use relationship queries to collapse multiple queries into one

Instead of querying Accounts and then querying Contacts separately, pull both in one shot:

Apex Code

List<Account> accounts = [
    SELECT Id, Name,
           (SELECT Id, Email FROM Contacts)
    FROM Account
    WHERE Id IN :accountIds
];

3. Avoid SELECT inside decision logic

Putting a query inside an if block is a code smell. Even if it only fires for certain records, in bulk scenarios it’ll fire for every record matching that condition.

4. Cache repeated lookups

If your code queries the same custom metadata or settings records multiple times, cache the results in a static variable. Custom metadata queries still count toward your limit.

Apex Code

// Cache expensive queries as static variables
private static Map<String, My_Setting__mdt> cachedSettings;

public static Map<String, My_Setting__mdt> getSettings() {
    if (cachedSettings == null) {
        cachedSettings = new Map<String, My_Setting__mdt>();
        for (My_Setting__mdt s : [SELECT DeveloperName, Value__c FROM My_Setting__mdt]) {
            cachedSettings.put(s.DeveloperName, s);
        }
    }
    return cachedSettings;
}

Targeting 50,000 query rows: This separate limit catches developers who write efficient queries but return massive result sets. Always use WHERE clauses and LIMIT when you don’t need every record.


DML Statement Limits

150 DML Statements Per Transaction

DML (Data Manipulation Language) statements — INSERT, UPDATE, DELETE, UPSERT, MERGE, UNDELETE, and convertLead — are limited to 150 per transaction. Like SOQL queries, the limit tracks the number of statements, not the number of records affected.

The critical insight: one DML statement can process a list of up to 10,000 records. So the right approach is always to batch your records into collections before saving them.

Apex Code

// ❌ WRONG — one DML per record
for (Contact c : contactsToUpdate) {
    c.Title = 'Updated';
    update c; // One DML statement per iteration
}

// ✅ CORRECT — one DML for all records
for (Contact c : contactsToUpdate) {
    c.Title = 'Updated';
}
update contactsToUpdate; // One DML statement for everything

Bulkifying DML Operations

Bulkification means writing code that handles collections of records in a single operation rather than processing records one at a time. It’s not just a best practice — it’s the only way to write Apex that functions correctly in real-world bulk scenarios.

The standard pattern for trigger-based DML work:

Apex Code

trigger AccountTrigger on Account (after insert, after update) {
    List<Task> tasksToCreate = new List<Task>();
    
    for (Account acc : Trigger.new) {
        if (acc.Type == 'Customer') {
            tasksToCreate.add(new Task(
                Subject = 'Onboarding Call',
                WhatId = acc.Id,
                ActivityDate = Date.today().addDays(3)
            ));
        }
    }
    
    if (!tasksToCreate.isEmpty()) {
        insert tasksToCreate; // One insert for all tasks
    }
}

Notice the pattern: build a collection inside the loop, then perform the DML outside the loop. One DML statement regardless of how many accounts fired the trigger.

Handling mixed success/failure: When you bulk-insert a list, a failure in one record can fail the entire operation by default. Use Database.insert(records, false) to allow partial success and handle errors gracefully:

Apex Code

Database.SaveResult[] results = Database.insert(recordsToInsert, false);
for (Database.SaveResult sr : results) {
    if (!sr.isSuccess()) {
        for (Database.Error err : sr.getErrors()) {
            // Log the error, notify, handle it
            System.debug('Error: ' + err.getMessage());
        }
    }
}

CPU Time Limits

10,000ms Synchronous Limit

The CPU time limit measures how long Apex code actually spends executing on the server — not wall clock time, but pure processing time. The synchronous limit is 10,000 milliseconds (10 seconds). Asynchronous jobs get 60,000ms (60 seconds).

CPU time doesn’t count time spent waiting for SOQL results, callouts, or DML to complete. It only counts code execution. So a trigger that fires one massive query and waits for it won’t hit CPU limits on the query itself — but any complex in-memory processing of the results definitely will.

Common CPU time killers:

  • Nested loops over large collections
  • Heavy string manipulation (especially regex) at scale
  • Recursive computations without memoization
  • Loading entire complex object graphs into memory

Reducing CPU Time

Replace nested loops with maps

The most reliable way to reduce CPU consumption is replacing O(n²) nested loops with O(n) map lookups:

Apex Code

// ❌ SLOW — O(n²) nested loop
for (Order o : orders) {
    for (Account a : accounts) {
        if (o.AccountId == a.Id) {
            o.Account_Name__c = a.Name;
        }
    }
}

// ✅ FAST — O(n) map lookup
Map<Id, Account> accountMap = new Map<Id, Account>(accounts);
for (Order o : orders) {
    if (accountMap.containsKey(o.AccountId)) {
        o.Account_Name__c = accountMap.get(o.AccountId).Name;
    }
}

With 1,000 orders and 1,000 accounts, the nested loop approach executes 1,000,000 comparisons. The map approach executes 1,000.

Avoid String operations in tight loops

String.format(), regex matching, and string concatenation are all more expensive than they look. Build strings once, reuse them, or switch to String.join() for concatenation:

Apex Code

// ❌ SLOW — string concatenation in a loop
String result = '';
for (String item : items) {
    result += item + ','; // Creates a new String object every iteration
}

// ✅ FAST — use join
String result = String.join(items, ',');

Minimize object instantiation in hot paths

Creating new objects is cheap, but doing it in tight loops over thousands of records adds up. Pre-instantiate objects outside loops where possible and reuse them.


Heap Size Limits

Managing Memory

The heap size limit caps how much data your Apex transaction can hold in memory at once — 6 MB synchronously, 12 MB asynchronously. Hit this limit and you’ll get System.LimitException: Apex heap size too large.

Large data sets are the usual culprit. If you’re querying 50,000 records with wide schemas (many fields), the result set alone can push you over the heap limit.

Key strategies for heap management:

1. Query only the fields you need

Every extra field in your SELECT increases heap consumption. SELECT * doesn’t exist in SOQL, but developers often select far more than they need out of habit.

Apex Code

// ❌ Wasteful — selects every field
[SELECT Id, Name, Phone, Fax, BillingStreet, BillingCity, ... FROM Account WHERE ...]

// ✅ Lean — selects only what the code uses
[SELECT Id, Name FROM Account WHERE ...]

2. Nullify large collections when you’re done with them

Once you’ve processed a collection and no longer need it, set it to null. Salesforce’s garbage collector will reclaim that memory.

Apex Code

List<SObject> bigList = [SELECT ... FROM ... WHERE ...];
processList(bigList);
bigList = null; // Free the memory before the next phase

3. Use SOQL for loops for large result sets

The standard SOQL loop pattern loads all records into a list at once. The SOQL for loop streams records in chunks of 200, dramatically reducing peak heap usage:

Apex Code

// ❌ Loads all 50,000 records into memory at once
List<Account> accounts = [SELECT Id FROM Account LIMIT 50000];

// ✅ Processes 200 records at a time — lower memory footprint
for (Account acc : [SELECT Id FROM Account LIMIT 50000]) {
    // process each record
}

For very large data volumes, Batch Apex is the right tool — it was designed specifically to handle millions of records without heap pressure.


Callout Limits

Apex can make external HTTP requests (callouts) to external systems, but governor limits apply there too. Per transaction, you can make 100 callouts, with each request timing out after 120 seconds.

A few important caveats:

  • You cannot mix DML and callouts in the same execution context without using @future(callout=true) or Queueable. Doing so throws System.CalloutException: You have uncommitted work pending.
  • Callout response size is capped at 6 MB
  • Callouts from triggers must use asynchronous patterns

The right architecture for callout-heavy integrations almost always involves Queueable Apex with System.enqueueJob(), which lets you chain multiple callouts and handle retry logic cleanly.

Apex Code

public class IntegrationQueueable implements Queueable, Database.AllowsCallouts {
    public void execute(QueueableContext context) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('https://api.example.com/data');
        req.setMethod('GET');
        
        HttpResponse res = new Http().send(req);
        // Process response...
    }
}

Using the Limits Class

Salesforce gives you a built-in Limits class that lets you check your current consumption against the maximum allowed — at runtime. This is your best debugging tool and your guard against unexpected exceptions in production.

Key methods you should know:

Apex Code

// SOQL
System.debug('SOQL used: ' + Limits.getQueries() + ' / ' + Limits.getLimitQueries());

// DML
System.debug('DML used: ' + Limits.getDmlStatements() + ' / ' + Limits.getLimitDmlStatements());

// CPU Time
System.debug('CPU ms used: ' + Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime());

// Heap
System.debug('Heap used: ' + Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize());

You can use these defensively in production code to bail out gracefully before hitting a hard limit:

Apex Code

if (Limits.getQueries() > 90) {
    // Approaching SOQL limit — enqueue remaining work asynchronously
    System.enqueueJob(new ProcessRemainingRecordsQueueable(remainingIds));
    return;
}

This pattern — checking limits and routing to async processing when close to the edge — is especially valuable in complex trigger frameworks where multiple handlers are competing for the same query budget.


Bulkification Techniques

Bulkification is the single most important skill in Apex development. Non-bulkified code that passes code review today will fail in production tomorrow when someone runs a data loader import.

The golden rules:

Rule 1: Never query or DML inside a loop. This is non-negotiable. Period.

Rule 2: Use trigger.newMap and trigger.oldMap instead of trigger.new for lookups.

apex

// Access records directly by Id — O(1) lookups
Map<Id, Account> newAccounts = trigger.newMap;
Map<Id, Account> oldAccounts = trigger.oldMap;

// Check if specific field changed
for (Id accountId : trigger.newMap.keySet()) {
    Account newAcc = newAccounts.get(accountId);
    Account oldAcc = oldAccounts.get(accountId);
    
    if (newAcc.Status__c != oldAcc.Status__c) {
        // Handle status change
    }
}

Rule 3: Aggregate into collections, then act once.

Build lists of records to update, insert, or delete. Execute DML once per operation type at the end.

Rule 4: Use Maps for relationship lookups.

Any time you’re correlating two sets of records by Id, use a Map. It turns O(n²) lookups into O(n).

Rule 5: Test with bulk data.

Every trigger and batch class should have a test that loads 200+ records at once. If it only tests one record at a time, it’s not testing bulk behavior.

Apex Code

@isTest
static void testBulkInsert() {
    List<Account> accounts = new List<Account>();
    for (Integer i = 0; i < 200; i++) {
        accounts.add(new Account(Name = 'Test Account ' + i));
    }
    Test.startTest();
    insert accounts; // This should not throw LimitException
    Test.stopTest();
}

Query Optimization

Beyond avoiding queries in loops, there are structural optimizations that make individual queries faster and less likely to hit row limits.

Use selective filters. Salesforce’s query optimizer works best when your WHERE clause filters on indexed fields: Id, Name, Owner, standard lookup fields, and fields explicitly marked as External Id or Unique. Filtering on a formula field or a non-indexed custom field forces a full table scan.

Leverage query plan analysis. In the Developer Console, you can run EXPLAIN on a query to see how Salesforce will execute it. A “TableScan” result is a red flag — it means no index is being used.

Use LIMIT aggressively when you don’t need every record. If you’re checking whether at least one record matches a condition, use LIMIT 1 instead of pulling the entire result set.

Apex Code

// ❌ Pulls everything just to check existence
List<Case> cases = [SELECT Id FROM Case WHERE AccountId = :accId AND Status = 'Open'];
Boolean hasOpenCase = !cases.isEmpty();

// ✅ Pulls one row maximum
Boolean hasOpenCase = ![SELECT Id FROM Case WHERE AccountId = :accId AND Status = 'Open' LIMIT 1].isEmpty();

Consider SOSL for full-text searches. SOSL (Salesforce Object Search Language) is often more efficient than SOQL for searching text fields across multiple objects. It counts as one query statement regardless of how many objects you search.


Asynchronous Processing for Large Data

When your operation genuinely can’t fit in a single synchronous transaction, asynchronous processing is the answer — not a workaround, but the designed solution.

Batch Apex is the right tool for processing millions of records. It automatically chunks your data and runs each chunk in a separate transaction, giving each chunk its own full set of governor limits.

Apex Code

global class AccountBatchProcessor implements Database.Batchable<SObject> {
    global Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator('SELECT Id, Name FROM Account WHERE IsActive__c = true');
    }
    
    global void execute(Database.BatchableContext bc, List<Account> scope) {
        // Each execute() call gets its own governor limits
        // scope is a chunk of 200 records by default
        List<Account> toUpdate = new List<Account>();
        for (Account acc : scope) {
            acc.Description = 'Processed ' + System.now();
            toUpdate.add(acc);
        }
        update toUpdate;
    }
    
    global void finish(Database.BatchableContext bc) {
        // Runs once after all chunks complete
        System.debug('Batch complete');
    }
}

Call it with: Database.executeBatch(new AccountBatchProcessor(), 200);

Queueable Apex is ideal for chained operations, complex processing that doesn’t need Batch’s chunking, and operations that require callouts. It also supports typed parameters, unlike Future methods.

Future methods are the simplest async option but have the most limitations: no chaining, no monitoring, and parameters must be primitive types. Use them for simple one-off async operations like callouts after DML.

When to use each:

ScenarioRecommended Approach
Process 1M+ recordsBatch Apex
Chain multiple async stepsQueueable Apex
Fire-and-forget calloutFuture method
Scheduled nightly jobScheduled Apex → Batch/Queueable
Real-time event processingPlatform Events → Queueable

Monitoring and Debugging

You can’t optimize what you can’t measure. Here’s how to get visibility into governor limit consumption.

Developer Console Logs show you every Limits checkpoint during execution. Look for “LIMIT_USAGE_FOR_NS” entries to see the full breakdown of what your transaction consumed.

Apex Flex Queue and Job Monitor (Setup → Apex Jobs) show running and completed async jobs, including whether they failed due to limit exceptions.

Event Monitoring (available in Performance and Unlimited Editions) captures detailed telemetry on every transaction, including CPU time, SOQL counts, and heap usage. It’s the right tool for diagnosing performance issues at scale.

Custom limit logging is valuable for long-running transactions where you want to pinpoint where limits are being consumed:

Apex Code

public class LimitLogger {
    public static void checkpoint(String label) {
        System.debug(label + ' | SOQL: ' + Limits.getQueries() 
            + '/' + Limits.getLimitQueries()
            + ' | DML: ' + Limits.getDmlStatements()
            + '/' + Limits.getLimitDmlStatements()
            + ' | CPU: ' + Limits.getCpuTime() + 'ms'
            + ' | Heap: ' + Limits.getHeapSize() + 'B');
    }
}

Drop LimitLogger.checkpoint('After contact query'); at key points in your code during development and you’ll quickly see where consumption spikes.

Unit test assertions on limits let you catch regressions before they reach production:

Apex Code

@isTest
static void testLimitUsage() {
    // Setup test data...
    
    Test.startTest();
    Integer queriesBefore = Limits.getQueries();
    MyTriggerHandler.processBulk(testRecords);
    Integer queriesUsed = Limits.getQueries() - queriesBefore;
    Test.stopTest();
    
    System.assert(queriesUsed <= 3, 'Expected ≤3 SOQL queries, got: ' + queriesUsed);
}

Conclusion

Governor limits are one of the things that genuinely separates experienced Salesforce developers from those still on the learning curve. They feel frustrating at first, but once you internalize the patterns — bulk everything, query outside loops, lean toward async for volume work — they stop being obstacles and start being guardrails that push you toward better code.

The key habits to build:

  • Always write triggers assuming hundreds of records, not one
  • Collect first, act once — for both queries and DML
  • Use Maps for any relationship lookup
  • Reach for Batch Apex the moment a job might touch more than a few thousand records
  • Monitor limit consumption during development, not just in production

Salesforce governor limits aren’t going away. But with the right patterns, you won’t need them to.


Quick Reference Card

LimitSynchronousAsynchronous
SOQL Queries100200
Query Rows Returned50,00050,000
DML Statements150150
DML Rows10,00010,000
CPU Time10,000 ms60,000 ms
Heap Size6 MB12 MB
Callouts100100
Future Calls from Single Tx50
Queueable Jobs Enqueued50
Email Invocations1010

Top 5 Apex Governor Limit Rules:

  1. Never query inside a for loop
  2. Never DML inside a for loop
  3. Use Map<Id, SObject> for relationship lookups
  4. Test with 200+ records in every unit test
  5. Use Limits class to guard against unexpected exceptions

FAQ

Q: What happens when you hit a Salesforce governor limit? When Apex code exceeds a governor limit, Salesforce throws a System.LimitException that terminates the transaction immediately. All changes made in that transaction are rolled back automatically. No partial saves occur, which protects data integrity.

Q: Do managed package queries count toward my governor limits? Yes. All SOQL queries, DML statements, and CPU time consumed by managed packages in your org count toward the same per-transaction limits as your custom code. When evaluating ISV packages, it’s worth understanding their resource footprint, especially if they include triggers.

Q: How do I find out which code is consuming the most SOQL queries? Use the Developer Console’s execution log with the “SOQL_EXECUTE_BEGIN” filter to see every query. For production issues, Event Monitoring provides detailed per-transaction logs. The Limits class placed at strategic checkpoints also helps isolate consumption during development.

Q: What’s the difference between CPU time and total transaction time? CPU time counts only the time Apex code is actually running on the server. Total transaction time includes waits for SOQL results, DML processing, and callout responses. A transaction can run for many minutes total while consuming only a fraction of that in CPU time.

Q: Can you bypass governor limits in Apex? No. Governor limits are enforced by the Salesforce platform and cannot be bypassed through any supported mechanism. Some limits are configurable (like certain email limits), but core limits like SOQL queries, DML, and CPU time are hard limits. The correct approach is always to design code that works within them.

Q: Is SOQL in a loop always bad? Putting SOQL inside a for loop that iterates over sObject records is always a problem waiting to happen. However, a loop that only runs a bounded, small number of iterations (like iterating over 3 configuration records) may be acceptable in context. The risk is that “bounded” assumptions often break down. The safest default is to always bulk-collect before your loops.

Q: When should I use Batch Apex vs. Queueable Apex? Use Batch Apex when you need to process large volumes of records (thousands to millions) using a QueryLocator to chunk the data automatically. Use Queueable when you need chained async steps, complex processing with typed parameters, or callouts in an async context. Batch is built for data volume; Queueable is built for flexibility.

Discover more from SFDCRocks247

Subscribe now to keep reading and get access to the full archive.

Continue reading