SOQL Query Optimization in Salesforce: 10 Techniques to Avoid Governor Limit Errors
By Firus Hanov ยท ยท 8 min read
Master 10 SOQL optimization techniques: avoid queries in loops, use selective filters, relationship queries, aggregates, LIMIT/OFFSET, and Database.QueryLocator.

Bad SOQL is the #1 source of governor limit failures in Salesforce. A query that works perfectly with 1,000 records can bring an entire org to its knees when data grows to 100,000.
The good news: SOQL optimization is learnable. There's a finite set of patterns to follow, and once you internalize them, you'll write queries that scale from day one.
Here are the 10 most impactful techniques for writing efficient, limit-safe SOQL in Salesforce. ๐ฅ
Technique 1: Never Put SOQL Inside a Loop
This is the cardinal rule of Salesforce development. If you violate only one rule, make sure it's not this one.
// โ This fails after 100 records
for (Contact c : contacts) {
Account acc = [SELECT Name FROM Account WHERE Id = :c.AccountId];
}
// โ
One query, map lookup inside the loop
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
accountIds.add(c.AccountId);
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
for (Contact c : contacts) {
Account acc = accountMap.get(c.AccountId); // O(1) lookup, zero SOQL
}
Every trigger, batch job, and service method you write must follow this pattern. There are no exceptions.
Technique 2: Query Only the Fields You Need
Salesforce transmits field data over the network and stores it in heap memory. Querying unused fields wastes both.
// โ Querying fields you don't use
List<Account> accounts = [
SELECT Id, Name, BillingStreet, BillingCity, BillingState,
Phone, Website, Industry, Type, AnnualRevenue,
NumberOfEmployees, Description, OwnerId, CreatedDate,
LastModifiedDate
FROM Account
];
// โ
Only what you actually use
List<Account> accounts = [SELECT Id, Name, OwnerId FROM Account WHERE Id IN :ids];
With large datasets, this makes a measurable difference in heap consumption and execution time.
Technique 3: Use Selective Filters on Indexed Fields
Salesforce uses indexes to speed up queries. A "selective" query filters on an indexed field and returns a small percentage of total records โ these queries are fast.
Automatically indexed fields:
IdNameOwnerIdCreatedDateLastModifiedDate- Any field marked as External ID or Unique
- Any custom index requested from Salesforce Support
// โ
SELECTIVE โ uses the Id index
SELECT Id, Name FROM Account WHERE Id IN :accountIds
// โ
SELECTIVE โ uses OwnerId index
SELECT Id, Name FROM Account WHERE OwnerId = :currentUserId
// โ NON-SELECTIVE โ full table scan, slow at scale
SELECT Id FROM Account WHERE Description LIKE '%VIP%'
// โ NON-SELECTIVE โ leading wildcard forces full scan
SELECT Id FROM Account WHERE Name LIKE '%Corp%'
// โ
BETTER โ trailing wildcard only uses index
SELECT Id FROM Account WHERE Name LIKE 'Corp%'
Technique 4: Filter at the Database Level, Not in Apex
Every record you pull from the database into Apex consumes heap. Filter in SOQL, not in a for loop.
// โ WRONG โ loads 10,000 records then throws 9,900 away
List<Account> allAccounts = [SELECT Id, Type FROM Account];
List<Account> customers = new List<Account>();
for (Account acc : allAccounts) {
if (acc.Type == 'Customer') {
customers.add(acc);
}
}
// โ
CORRECT โ database returns only what you need
List<Account> customers = [SELECT Id, Type FROM Account WHERE Type = 'Customer'];
Technique 5: Use Relationship Queries to Avoid Extra Queries
Relationship queries (subqueries) let you fetch related records in a single SOQL query instead of two.
// โ Two queries when one will do
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :ids];
List<Contact> contacts = [SELECT Id, AccountId, Name FROM Contact WHERE AccountId IN :ids];
// โ
One query with a subquery
List<Account> accounts = [
SELECT Id, Name,
(SELECT Id, Name, Email FROM Contacts ORDER BY Name LIMIT 10)
FROM Account
WHERE Id IN :ids
];
for (Account acc : accounts) {
List<Contact> relatedContacts = acc.Contacts; // No additional query
}
Limits on relationship queries:
- Child-to-parent: up to 55 levels deep
- Parent-to-child (subqueries): up to 20 per query
Technique 6: Use Aggregate Queries for Summaries
For roll-up calculations, aggregate SOQL is far more efficient than loading all records and calculating in Apex.
// โ WRONG โ loads every Opportunity record just to count/sum
List<Opportunity> opps = [SELECT Amount FROM Opportunity WHERE AccountId = :accId];
Integer count = opps.size();
Decimal totalAmount = 0;
for (Opportunity opp : opps) {
totalAmount += opp.Amount;
}
// โ
CORRECT โ database does the math
AggregateResult[] results = [
SELECT COUNT(Id) oppCount, SUM(Amount) totalAmount
FROM Opportunity
WHERE AccountId = :accId
AND StageName = 'Closed Won'
];
Integer count = (Integer) results[0].get('oppCount');
Decimal totalAmount = (Decimal) results[0].get('totalAmount');
For group-by summaries:
AggregateResult[] results = [
SELECT StageName, COUNT(Id) oppCount, SUM(Amount) totalValue
FROM Opportunity
WHERE AccountId IN :accountIds
GROUP BY StageName
ORDER BY SUM(Amount) DESC
];
Technique 7: Use LIMIT and OFFSET for Pagination
When you don't need all records at once, limit the result set:
// Get the 10 most recently modified accounts
List<Account> recentAccounts = [
SELECT Id, Name, LastModifiedDate
FROM Account
ORDER BY LastModifiedDate DESC
LIMIT 10
];
// Pagination โ get page 3 (records 21-30)
List<Account> page3 = [
SELECT Id, Name
FROM Account
ORDER BY Name
LIMIT 10
OFFSET 20
];
Note: OFFSET has a maximum value of 2,000. For large dataset pagination, use WHERE Id > :lastId patterns or Database.QueryLocator in batch jobs.
Technique 8: Use WITH SECURITY_ENFORCED
This enforces field-level security โ if the running user doesn't have access to a queried field, it throws an exception.
// โ
Always include in user-context queries
List<Account> accounts = [
SELECT Id, Name, AnnualRevenue
FROM Account
WHERE Id IN :ids
WITH SECURITY_ENFORCED
];
Skip this only in without sharing classes designed for admin/system operations where FLS bypass is intentional.
Technique 9: Use Database.QueryLocator for Large Datasets
When processing large volumes (more than 10,000 records), use Database.QueryLocator in Batch Apex. It supports up to 50 million records.
public class AccountBatchProcessor implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Name, AnnualRevenue FROM Account WHERE CreatedDate = THIS_YEAR'
);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
// Process...
}
update scope;
}
public void finish(Database.BatchableContext bc) {
// Send notification, kick off next job, etc.
}
}
Database.executeBatch(new AccountBatchProcessor(), 200);
Technique 10: Monitor Your Query Performance with Debug Logs
Before optimizing, you need to measure.
public class QueryMonitor {
public static void checkQueryUsage(String context) {
System.debug(
LoggingLevel.WARN,
'[' + context + '] SOQL: ' + Limits.getQueries() + '/' + Limits.getLimitQueries() +
' | Records: ' + Limits.getQueryRows() + '/' + Limits.getLimitQueryRows() +
' | CPU: ' + Limits.getCpuTime() + 'ms'
);
}
}
Also use the Query Plan Tool in Developer Console to see whether Salesforce is using an index or doing a full scan.
Quick Reference: SOQL Anti-Patterns to Eliminate
| Anti-Pattern | What to Do Instead |
|---|---|
SOQL inside for loop | Query before loop, use Map for lookups |
| SELECT * (all fields) | Select only fields you use |
Negative filters (!=, NOT IN) | Reframe as positive filters where possible |
Leading wildcards (LIKE '%word%') | Use trailing wildcards only |
| Loading all records to count/sum | Use COUNT(), SUM() in aggregate query |
| Manual filtering in Apex | Filter in WHERE clause |
| Two queries when a subquery works | Use relationship query |
Summary
SOQL optimization is one of the highest-leverage skills in Salesforce development. Apply these 10 techniques and your code will handle any data volume:
- Never query inside loops
- Select only needed fields
- Filter on indexed fields for selectivity
- Push filters into WHERE, not Apex loops
- Use relationship queries to reduce total query count
- Aggregate at the database level
- Limit result sets with LIMIT and OFFSET
- Always use WITH SECURITY_ENFORCED
- Use Database.QueryLocator for large datasets
- Monitor with debug logs and Query Plan Tool
Need a SOQL performance review? Book a session with ApexSensei and I'll audit your most expensive queries. Start practicing on ApexSensei โ
What's the most surprising governor limit issue you've hit in production? Share below. ๐ฌ