ApexSensei

Blog

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.

SOQL Query Optimization in Salesforce: 10 Techniques to Avoid Governor Limit Errors

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:

  • Id
  • Name
  • OwnerId
  • CreatedDate
  • LastModifiedDate
  • 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-PatternWhat to Do Instead
SOQL inside for loopQuery 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/sumUse COUNT(), SUM() in aggregate query
Manual filtering in ApexFilter in WHERE clause
Two queries when a subquery worksUse 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:

  1. Never query inside loops
  2. Select only needed fields
  3. Filter on indexed fields for selectivity
  4. Push filters into WHERE, not Apex loops
  5. Use relationship queries to reduce total query count
  6. Aggregate at the database level
  7. Limit result sets with LIMIT and OFFSET
  8. Always use WITH SECURITY_ENFORCED
  9. Use Database.QueryLocator for large datasets
  10. 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. ๐Ÿ’ฌ

Practice Apex free on ApexSensei