Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Governor limits

System.LimitException: Too many SOSL queries: 21

Apex caps a single transaction at 20 SOSL `FIND` queries. Hit 21 and the transaction throws. Same family of bug as the SOQL 101 cap — almost always a SOSL inside a loop. Cure: lift it out and search for everything in one query.

Also seen asToo many SOSL queries: 21·Too many SOSL queries·SOSL query limit

A trigger that enriches incoming leads by searching for matching accounts crashes the moment someone imports a batch of 21 records. The log shows System.LimitException: Too many SOSL queries: 21. The code is a tidy little loop that searches once per lead. It worked when QA tested with 5 leads, and again with 10. The 21st lead is the one that detonates the transaction.

The 20-call cap

Apex caps a single transaction at 20 SOSL (Salesforce Object Search Language) queries. Calling SOSL a 21st time throws LimitException: Too many SOSL queries: 21.

The cap is a sibling of the SOQL cap, but smaller. SOQL allows 100 queries per transaction; SOSL allows 20. The reason for the lower number is operational: SOSL queries hit the org-wide full-text search index, which is far more expensive per call than a SOQL query against a single object's storage. The platform protects the search infrastructure by capping how often any one transaction can hit it.

The cap counts every [FIND ... RETURNING ...] in your code, plus every Search.query(...) call. Both invoke the search index; both count.

The broken example

A trigger that enriches Lead records by searching across multiple objects for a similar account:

trigger LeadEnrichmentTrigger on Lead (before insert) {
    for (Lead l : Trigger.new) {
        // SOSL inside the per-record loop. Throws on lead #21.
        List<List<SObject>> matches = [
            FIND :l.Company IN ALL FIELDS RETURNING Account(Id, Name, Industry)
        ];
        List<Account> accounts = (List<Account>) matches[0];
        if (!accounts.isEmpty()) {
            l.Matched_Account__c = accounts[0].Id;
        }
    }
}

The intent is clear: for each new lead, search across the org for an account whose name matches the lead's company, and link them. The code reads cleanly. It also runs one SOSL per lead, which works for tests with a few leads and fails on production batches.

The shape is identical to the classic "SOQL in a loop" bug (Too many SOQL queries: 101), but it hits a lower cap. A 30-record SOQL-in-loop bug would fire its limit at 101; the SOSL-in-loop bug fires at 21.

The fix: one SOSL for everything

SOSL is designed for cross-object full-text search and naturally accepts multiple search terms. The bulk-safe pattern collects every search term in a list, runs one SOSL with all terms joined, and then routes results back to the right lead in a map.

trigger LeadEnrichmentTrigger on Lead (before insert) {
    Set<String> companyNames = new Set<String>();
    for (Lead l : Trigger.new) {
        if (!String.isBlank(l.Company)) companyNames.add(l.Company);
    }
    if (companyNames.isEmpty()) return;

    // Build a single OR-joined SOSL query covering every company.
    // Escape each term to prevent SOSL injection.
    List<String> escaped = new List<String>();
    for (String n : companyNames) {
        escaped.add('"' + String.escapeSingleQuotes(n) + '"');
    }
    String searchTerm = String.join(escaped, ' OR ');

    List<List<SObject>> results = Search.query(
        'FIND \'' + searchTerm + '\' IN NAME FIELDS RETURNING Account(Id, Name)'
    );
    List<Account> matches = (List<Account>) results[0];

    Map<String, Id> accountIdByName = new Map<String, Id>();
    for (Account a : matches) {
        accountIdByName.put(a.Name.toLowerCase(), a.Id);
    }

    for (Lead l : Trigger.new) {
        if (!String.isBlank(l.Company)) {
            Id matchedId = accountIdByName.get(l.Company.toLowerCase());
            if (matchedId != null) {
                l.Matched_Account__c = matchedId;
            }
        }
    }
}

One SOSL call, regardless of the number of leads. The cap is no longer in play.

A subtle but important detail: the String.escapeSingleQuotes call. SOSL accepts the same injection risks as SQL. Without escaping, a lead with the company name Acme" OR Name LIKE "% could rewrite the query to return every account. Always escape every user-controllable term.

The fixed example, end to end

A bulk-safe lead enrichment service refactored into a callable class:

public class LeadEnrichmentService {
    public static void linkMatchingAccounts(List<Lead> leads) {
        if (leads == null || leads.isEmpty()) return;

        Set<String> companies = new Set<String>();
        for (Lead l : leads) {
            if (!String.isBlank(l.Company)) companies.add(l.Company.trim());
        }
        if (companies.isEmpty()) return;

        Map<String, Id> accountIdByName = findAccountsByName(companies);

        for (Lead l : leads) {
            if (String.isBlank(l.Company)) continue;
            Id matched = accountIdByName.get(l.Company.trim().toLowerCase());
            if (matched != null) {
                l.Matched_Account__c = matched;
            }
        }
    }

    private static Map<String, Id> findAccountsByName(Set<String> names) {
        List<String> escaped = new List<String>();
        for (String n : names) {
            escaped.add('"' + String.escapeSingleQuotes(n) + '"');
        }
        String searchTerm = String.join(escaped, ' OR ');

        List<List<SObject>> results = Search.query(
            'FIND \'' + searchTerm + '\' IN NAME FIELDS RETURNING Account(Id, Name)'
        );
        Map<String, Id> byName = new Map<String, Id>();
        for (SObject so : results[0]) {
            Account a = (Account) so;
            byName.put(a.Name.toLowerCase(), a.Id);
        }
        return byName;
    }
}

trigger LeadEnrichmentTrigger on Lead (before insert) {
    LeadEnrichmentService.linkMatchingAccounts(Trigger.new);
}

The trigger delegates to a service class with a clean signature. The service does one SOSL no matter the input size. The function is unit-testable independently of the trigger context.

SOQL vs SOSL: when to use which

The cap distinction maps to the underlying difference between the two query languages.

SOQL queries one object at a time with structured criteria. Best for "give me records that match these conditions." Up to 100 calls per transaction.

SOSL does full-text search across multiple objects in one call. Best for "find records that mention this phrase." Up to 20 calls per transaction.

If you can express the search as a SOQL WHERE field = value filter, SOQL is the right tool. The cap is higher and the cost per call is lower. If you genuinely need to search free-form text across many objects, SOSL is the right tool, and the cap is a reminder to bulkify.

Common cases where teams reach for SOSL but should reach for SOQL:

  • Searching by exact match on a single field. Use SOQL with WHERE field = :value.
  • Searching by Id (you have the id; you know which object). Use SOQL.
  • Filtering by a known prefix. SOQL with LIKE may suffice, depending on the prefix's selectivity.

Common cases where SOSL is right:

  • A user-facing search box that searches across Accounts, Contacts, Leads, and Opportunities simultaneously.
  • Searching for a substring in a long text or rich text field.
  • Searching across multiple fields on the same object where the field name isn't known in advance.

A subtle limit: rows per SOSL call

A single SOSL call returns up to 2,000 rows by default, distributed across the returned object types. If you need more, you can split the search into chunks or use Apex Limits to verify the response. The findAccountsByName example above returns up to 2,000 matches; an org with more than 2,000 accounts named like the leads' companies needs paging.

Paging in SOSL is less ergonomic than in SOQL. The recommended pattern: narrow the search with more specific terms (e.g., IN NAME FIELDS RETURNING Account(Id, Name LIMIT 200)) and accept that the result might miss some matches. For exhaustive search, fall back to SOQL with LIKE filtering.

A useful diagnostic before you optimize

Before refactoring, run a Limits.getSoslQueries() debug at the suspected hot spot:

System.debug('SOSL used: ' + Limits.getSoslQueries() + '/' + Limits.getLimitSoslQueries());

The output tells you how many SOSL calls have happened in the current transaction. If the number jumps unexpectedly between two debug lines, the code between them is making more SOSL calls than you thought. This is especially useful for diagnosing managed packages or framework code that adds SOSL behind the scenes.

Test patterns for SOSL-heavy code

Apex test mode doesn't index real data into the search index; SOSL queries return empty by default. To test SOSL-dependent code, use Test.setFixedSearchResults():

@isTest
static void enrichment_matchesByCompany() {
    Account a = new Account(Name = 'Acme Corp', Industry = 'Technology');
    insert a;

    Test.setFixedSearchResults(new List<Id>{ a.Id });

    Lead l = new Lead(
        FirstName = 'Test',
        LastName = 'Lead',
        Company = 'Acme Corp'
    );

    Test.startTest();
    insert l;
    Test.stopTest();

    Lead reloaded = [SELECT Matched_Account__c FROM Lead WHERE Id = :l.Id];
    System.assertEquals(a.Id, reloaded.Matched_Account__c);
}

Test.setFixedSearchResults lets you stub what SOSL would return. The trigger's logic runs as if the SOSL found the seeded account, regardless of whether the test framework has populated the index. Tests become deterministic.

Where SOSL hides in plain sight

Some Apex calls do SOSL internally without naming it. The most common:

  • Search.suggest(...): drives the auto-complete suggestions in Lightning's global search box.
  • Search.find(...) (deprecated but still in some legacy code).
  • Lightning App Builder's global search component when invoked from a Lightning page.

If your trigger or class indirectly triggers any of these (for example, via a record-triggered flow that calls a global-search action), the SOSL count climbs without you writing a literal [FIND ...].

The diagnostic is the same: log Limits.getSoslQueries() at suspected points and watch where the count rises.

A common architectural trade-off

For a feature like lead-to-account matching, teams often choose between:

Live SOSL on every save. Always up to date but expensive. Capped by the SOSL governor. Good for low-volume manual entry; bad for bulk imports.

Pre-built index in a custom object. A nightly batch job populates a Lead_Match_Cache__c table with pre-computed matches. The trigger reads from the cache via SOQL (no SOSL needed). Cheaper at save time; stale by up to a day.

External match service. Push the matching logic to an external system that has a real full-text index (Elasticsearch, Algolia). Salesforce calls out via REST. Avoids the SOSL cap entirely but adds an integration to maintain.

Choose based on freshness needs and volume. For most enrichment use cases, the cached pre-build option is the best balance of freshness, cost, and complexity.

When the limit is misleading

A small but real case: code that loops over external API results inside a save handler. Each result fetches a related Salesforce record via SOSL. If the API returns more than 20 hits, you blow the cap not because of bulk Salesforce processing but because of fan-out from the external system.

The fix is the same shape: collect all the external hits first, then run one SOSL with all the search terms. The fan-out pattern is structurally identical to the "SOSL in a loop" bug, just driven by an external loop instead of a Salesforce one.

The SOSL syntax reference, condensed

A few SOSL idioms worth knowing:

// Search all fields, return one object:
[FIND 'Acme' IN ALL FIELDS RETURNING Account(Id, Name)]

// Search name fields only (faster, more selective):
[FIND 'Acme' IN NAME FIELDS RETURNING Account(Id, Name)]

// Return multiple objects with different field lists:
[FIND 'Acme' IN ALL FIELDS RETURNING Account(Id, Name), Contact(Id, Email)]

// Add filters and ordering inside the returning clause:
[FIND 'Acme' IN ALL FIELDS
  RETURNING Account(Id, Name WHERE Industry = 'Technology' ORDER BY Name LIMIT 50)]

// Use wildcards (* matches anything; ? matches a single character):
[FIND 'Acm*' IN NAME FIELDS RETURNING Account]

The IN ALL FIELDS clause scans every indexed field, which is slower than IN NAME FIELDS or IN EMAIL FIELDS. Use the narrower clause when you know which type of field you're targeting; the search engine optimizes much better.

Indexing implications

SOSL relies on the platform's search index. Newly inserted records may take a few seconds to appear in SOSL results because the index updates asynchronously. SOQL doesn't have this delay; a record is visible to SOQL the moment its DML commits.

For tests, this is why Test.setFixedSearchResults exists. For production, it means real-time SOSL right after an insert may miss the just-inserted record. If your code does insert-then-search, wait briefly or use SOQL on the known id rather than SOSL on the indexed fields.

Async strategies for high-volume search

If you genuinely need many SOSL calls in a single business operation (say, enriching 1,000 leads each with multiple search dimensions), the synchronous transaction can't do it. The 20-call cap is hard.

The escape hatches:

Queueable chain. Process leads in groups of 20. After each group, queue a follow-up Queueable that handles the next group. Each chain link gets a fresh 20-call budget.

Batch Apex. Define a batch with a small batchSize (e.g., 20) so each execute() invocation processes at most 20 records. Each invocation gets its own governor budget. For a job with 1,000 records, the platform runs 50 execute calls, each with up to 20 SOSL calls.

External search service. Push the search work to a system that has higher capacity (Elasticsearch, Algolia). Salesforce calls it once per group via a single REST callout, gets back the match data, and applies it. The Salesforce side does one or two SOSL calls; the heavy lifting is external.

The async paths add latency and complexity. Use them when the synchronous cap genuinely doesn't fit, not as a workaround for code that wasn't bulkified.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Governor limit errors