Governor limits are one of the first things that surprise developers coming to Salesforce from other platforms. They impose strict ceilings on resources consumed by each Apex transaction, and exceeding them immediately raises an exception that rolls back execution. Understanding these limits means understanding how Salesforce really works.
Salesforce is a multi-tenant platform, thousands of organisations share the same infrastructure. To ensure one client's code doesn't monopolise resources at others' expense, Salesforce enforces per-transaction limits on each resource type. This isn't a bug or artificial limitation, it's the mechanism that keeps the platform stable at scale.
Every time an Apex trigger fires, a Flow calls Apex code, or a batch runs, a resource counter starts. When a limit is reached, Salesforce raises a System.LimitException and rolls back the entire transaction.
💡 Check limits in real time: the Limits class exposes current counters. Limits.getQueries() returns SOQL consumed, Limits.getLimitQueries() the maximum limit. Useful for instrumenting code and detecting overruns before they hit production.
This is the most frequent mistake on Salesforce, and it catches even experienced developers coming from other languages. Putting a SOQL query inside a for loop consumes one query per iteration, and at 101 records, it's over.
// SOQL in loop = LimitException from 101 records for (Account acc : trigger.new) { List<Contact> contacts = [ SELECT Id, Email FROM Contact WHERE AccountId = :acc.Id // ← one query per Account! ]; // processing... }
// A single query for all records in the batch 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, Email, AccountId FROM Contact WHERE AccountId IN :accountIds // ← a single query ]) { if (!contactsByAccount.containsKey(c.AccountId)) { contactsByAccount.put(c.AccountId, new List<Contact>()); } contactsByAccount.get(c.AccountId).add(c); }
The same mistake exists on the DML side. Doing an update or insert inside a loop consumes one DML operation per iteration. At 151 records, the limit is reached.
for (Account acc : accountsToUpdate) { acc.Description = 'Updated'; update acc; // ← one DML per Account! }
List<Account> toUpdate = new List<Account>(); for (Account acc : accountsToUpdate) { acc.Description = 'Updated'; toUpdate.add(acc); } update toUpdate; // ← a single DML for all
The CPU time limit (10 seconds synchronous) only counts pure Apex execution time, not SOQL query wait time or HTTP call wait time. In practice, it's rarely hit on simple code, but becomes critical with:
The main workaround: go asynchronous. A @future, Queueable or Batchable has 60 seconds of CPU instead of 10.
The heap size limit (6 MB synchronous) is often hit when loading large collections into memory, for example retrieving all fields with SELECT * or storing large lists in static variables.
Database.QueryLocator rather than a list for very large volumes| Limit | Synchronous | Asynchronous | Classic mistake |
|---|---|---|---|
| SOQL Queries | 100 | 200 | SOQL in loop |
| SOQL Rows | 50,000 | 50,000 | SELECT without filter |
| DML Statements | 150 | 150 | DML in loop |
| DML Rows | 10,000 | 10,000 | Batch too large |
| CPU Time | 10s | 60s | Nested loops |
| Heap Size | 6 MB | 12 MB | SELECT * on large object |
| HTTP Callouts | 100 | 100 | Callout in trigger |
| Future methods | 50 | — | @future in loop |
Each Apex trigger can receive up to 200 records in the same batch. All code must be written to process a collection, never a single record. This is rule #1 of Apex development.
When processing risks exceeding synchronous limits, move it asynchronous. Salesforce offers several patterns:
@future : for simple non-chainable operations (HTTP callouts from a trigger…)Queueable : for chainable operations with contextBatchable : for high-volume processing (up to 50M records)Schedulable : for scheduled processingIn development, instrumenting code with System.debug(Limits.getQueries()) allows monitoring real consumption and identifying hotspots before they reach production.
🚨 Watch out for cascading triggers: a trigger on object A that updates object B can fire a trigger on B, which updates C… Governor limits apply to the entire transaction, not each trigger individually. An uncontrolled cascade can exceed limits even with perfectly bulkified individual code.
Governor limits aren't an obstacle, they're guardrails that force writing performant, scalable code. A Salesforce developer who masters limits naturally writes bulkified code, thinks in terms of collections rather than individual records, and chooses the right async pattern for the context. This mastery is what distinguishes code that holds in production from code that breaks as soon as volume increases.
Architecture, Apex development, code review, I can support you. Reply within 24 hours.
Let's talk →