You get 100 SOQL queries per synchronous transaction (200 async), shared by everything in it — your code, other triggers, and Flows. Hitting 101 means a query is inside a loop, usually a per-record query in a trigger that a bulk job just invoked with 200 records. The fix is always the same shape: collect IDs into a set, query once outside the loop, look results up from a map. Don't reach for @future.
What the error actually means
Salesforce allows 100 SOQL queries per synchronous transaction and 200 in an async one. The number in the message — 101 — is the query that crossed the line, and because System.LimitException can't be caught, the whole transaction rolls back. There is no config setting, support ticket, or license tier that raises it.
The word doing the work is transaction. The budget isn't per class or per trigger — it's shared by every piece of code the transaction touches: your trigger, the trigger it causes on a related object, the record-triggered Flows on both, and any unmanaged code a colleague added last quarter. (Certified managed packages get their own separate limits; everything else shares yours.) That's why the stack trace often points at innocent code — the query that threw is just the one that arrived after 100 others already ran.
The obvious culprit: a query in a loop
// Throws at ~100 contacts
for (Contact c : contacts) {
Account a = [SELECT OwnerId FROM Account WHERE Id = :c.AccountId];
c.OwnerId = a.OwnerId;
}One query per iteration. Works in every demo, dies the first time someone processes a real batch. Adding LIMIT 1 doesn't help — the limit counts queries issued, not rows returned.
The sneakier culprit: one query per record, in a trigger
The version that reaches production doesn't look like a loop. It looks like a helper method that queries for a single record — clean, readable, and called from a trigger:
// Looks fine. Isn't.
public static void enrich(List<Contact> contacts) {
for (Contact c : contacts) {
c.Region__c = RegionService.lookupRegion(c.AccountId); // queries inside
}
}Triggers receive records in chunks of up to 200. A user editing one Contact runs one query — fine. A Data Loader run, a batch job, or an integration upserting 200 Contacts hands your trigger 200 records in one invocation, and the helper fires 200 queries. The code was never wrong for one record; it was never right for two hundred. This is why the error characteristically appears months after deployment, during someone else's data migration.
Cascades compound it. Your Contact trigger updates Accounts, the Account trigger queries, its Flow queries — all against the same budget of 100. Each layer can be individually reasonable and the transaction still dies.
The fix: collect, query once, map
Every bulkification fix is the same three moves: collect the IDs you need, run one query outside the loop, and read from a map inside it.
public static void enrich(List<Contact> contacts) {
// 1. Collect
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
if (c.AccountId != null) accountIds.add(c.AccountId);
}
// 2. Query once
Map<Id, Account> accounts = new Map<Id, Account>(
[SELECT Region__c FROM Account WHERE Id IN :accountIds]
);
// 3. Map lookups inside the loop — zero queries
for (Contact c : contacts) {
Account a = accounts.get(c.AccountId);
if (a != null) c.Region__c = a.Region__c;
}
}One query whether the trigger gets 1 record or 200. The Map<Id, SObject> constructor that takes a query result is the most useful three characters of sugar in Apex — use it everywhere. For parent-child shapes, a single relationship query (SELECT Id, (SELECT ... FROM Contacts) FROM Account) often replaces two.
What not to do
- Don't move it to
@future. Async gets 200 queries instead of 100, so the per-record query still dies — at 201, later, and harder to debug. Async is for callouts and long work, not for outrunning a loop. - Don't split the DML into single-record calls. Updating records one at a time from a loop to keep trigger chunks small trades a SOQL limit problem for a DML limit problem (150 statements) and makes everything slower.
- Don't cache your way around a loop. A static-variable cache is a fine optimization for repeated identical lookups, but if the underlying access pattern is per-record, the first cold chunk still blows up. Fix the shape first.
Find it before production does
The reason this bug ships is that nothing in the default workflow makes query counts visible, and nobody writes the 200-record test when running it means a deploy and a several-minute wait. Two habits close the gap:
Assert on limits in your bulk tests. Limits.getQueries() tells you the count so far; a test that inserts 200 records and asserts the query count stays flat is a bulkification regression test:
@isTest
static void enrichIsBulkified() {
List<Contact> contacts = TestFactory.contactsWithAccounts(200);
Test.startTest();
insert contacts; // fires the full trigger stack
Integer queriesUsed = Limits.getQueries();
Test.stopTest();
System.assert(queriesUsed < 10,
'Trigger stack used ' + queriesUsed + ' queries for 200 records');
}Make the counts visible on every run. Nimbus tracks governor limits per test and runs the whole suite locally in seconds, so the 200-record test above is cheap enough to exist and the SOQL count for every test method is printed next to its result. A trigger stack that quietly grew from 4 queries per chunk to 40 shows up in the report — before a migration finds it for you.
Checklist
- 101 means per-record work. Find the loop; the stack trace names the victim, not always the culprit.
- Think in chunks of 200. Any code reachable from a trigger must survive 200 records per invocation.
- Collect → query once → map. The fix is always this shape.
- The budget is transaction-wide. Your code shares it with cascaded triggers and Flows.
- Write the 200-record test and assert on
Limits.getQueries().
See your query counts per test
Run your suite locally and get SOQL, DML, and CPU consumption for every test method — no org, no deploy, no debug logs to spelunk.