Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

The short version: your transaction has pulled back more than 50,000 records across all of its SOQL queries combined. Add a selective WHERE clause, replace “fetch everything and count it” with an aggregate query, or move the work into Batch Apex, where a Database.QueryLocator in the start method streams past this limit entirely. A SOQL for loop will not save you — it protects heap, not row count.

The limit is cumulative and identical in async

50,000 is the total number of records returned to your transaction by every query it issues, added together. Ten queries of 6,000 rows each fails just as surely as one query of 60,000.

Limit Synchronous Asynchronous
Records retrieved by SOQL queries 50,000 50,000
SOQL queries issued 100 200
Records retrieved by Database.getQueryLocator 10,000 10,000
Records processed by DML 10,000 10,000
Total heap size 6 MB 12 MB

Going asynchronous doubles your query count to 200 and doubles heap to 12 MB, but the row limit stays at 50,000. This trips up people who move a failing report generator into a @future method and watch it fail identically.

Cause 1: a query with no bound on the data volume

The query was written against a sandbox with 3,000 records and shipped to an org with 400,000.

// Fine in the sandbox. Fatal in production.
List<Task> allTasks = [SELECT Id, Subject, WhatId FROM Task WHERE IsClosed = false];

Two questions decide the fix. Does the code need the field values, or only how many there are? And does it need all of history, or a window?

// If you only need a count, retrieve one row instead of 400,000.
Integer openTasks = [
    SELECT COUNT() FROM Task WHERE IsClosed = false
];

// If you need the records, bound them.
List<Task> recent = [
    SELECT Id, Subject, WhatId FROM Task
    WHERE IsClosed = false AND CreatedDate = LAST_N_DAYS:30
    LIMIT 5000
];

COUNT() is the cheapest change available here. An aggregate query returns one row regardless of how many records it counted, so a limit failure becomes a limit non-event. The catch is that aggregate queries have their own separate limit on how many of them you can issue per transaction, so COUNT() in a loop reintroduces the problem in a new form.

Add LIMIT deliberately, not defensively. A LIMIT 5000 on a query that legitimately has 8,000 matching records does not fix a bug, it hides one — the code now silently produces wrong answers instead of throwing. Use LIMIT where truncation is genuinely correct, such as showing the ten most recent items.

Cause 2: the SOQL for loop that people think fixes this

This misconception is worth its own section because the advice is repeated so often.

// Still retrieves every matching row. Still counts every one of them.
for (Account a : [SELECT Id, Name FROM Account]) {
    // ...
}

The sObject-list form of a SOQL for loop processes records 200 at a time, which keeps only one chunk on the heap and is exactly the right tool for Apex heap size too large. It does nothing for the row limit, because every record it iterates was still retrieved by the query. If you are hitting 50,001, the for loop is not your fix.

// Correct use of the list form: bulk DML per chunk, low heap.
for (List<Contact> chunk : [
    SELECT Id, Status__c FROM Contact WHERE AccountId IN :accountIds
]) {
    for (Contact c : chunk) { c.Status__c = 'Reviewed'; }
    update chunk;
}

Cause 3: parent-child subqueries, where child rows count too

A subquery looks like one query returning 200 parents. It returns 200 parents plus every child row they matched.

List<Account> accounts = [
    SELECT Id, (SELECT Id, Amount FROM Opportunities)
    FROM Account WHERE Industry = 'Technology'
];

With 5,000 Accounts averaging 12 Opportunities, that’s 65,000 rows from a single statement. Query the children separately with their own filter and group them into a Map, or bound the subquery with its own WHERE and LIMIT — subqueries accept both.

There’s a related failure with the same root cause and a different message. Assigning a subquery result with 200 or more child records directly to a List throws a QueryException about an aggregate query having too many rows for direct assignment. Iterating the child relationship in a for loop instead of assigning it avoids that one.

Cause 4: many modest queries adding up

Each query returns 800 rows, which nobody would call excessive, and there are eighty of them across four handlers and a managed package.

You can find this in one pass without reading any code. Set the Apex Code log level to FINEST, reproduce the failure, and look at the CUMULATIVE_LIMIT_USAGE block at the end of the log. It reports the query-row total broken out by namespace in the LIMIT_USAGE_FOR_NS lines, and the log body lists each SOQL_EXECUTE_END with its row count. Sort by rows and you have your suspects.

Cause 5: the work genuinely needs 200,000 records

Sometimes the volume is real: a nightly recalculation, a data cleanup, a migration. Then no filter will help and Batch Apex is the answer, because the start method’s query locator streams rather than materializing:

public class ContactCleanupBatch implements Database.Batchable<sObject> {

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator(
            'SELECT Id, Status__c FROM Contact WHERE Status__c = null'
        );
    }

    public void execute(Database.BatchableContext bc, List<Contact> scope) {
        for (Contact c : scope) { c.Status__c = 'Unknown'; }
        update scope;
    }

    public void finish(Database.BatchableContext bc) { }
}

Governor limits reset for every execute invocation, so each chunk of 200 gets a fresh 50,000 rows. The 10,000-record ceiling on Database.getQueryLocator applies when you call it outside a batch start method, that is the distinction people miss when they test the locator in Anonymous Apex and conclude it can’t work.

State the cost plainly before you choose this. The job runs when the platform schedules it, not now. It commits chunk by chunk, so a failure in the middle leaves the org half-processed. And you need somewhere to report progress, because “did it work” is no longer answerable from the caller’s return value.

Diagnose it in code

System.debug('Query rows: ' + Limits.getQueryRows()
    + ' of ' + Limits.getLimitQueryRows()
    + ' | queries: ' + Limits.getQueries());

Print that between handler calls. The method where the number jumps by tens of thousands is the one to fix, and this takes less time than reading the code.

Confirm the fix

Test data volume, not test coverage, is what proves this fixed. Insert enough records that the old code would have failed, then assert the ceiling:

@IsTest
static void reportUsesAggregateNotRowScan() {
    // Enough parents that the old subquery approach would exceed 50,000.
    TestData.accountsWithOpportunities(1000, 60);

    Test.startTest();
    Integer before = Limits.getQueryRows();
    ReportService.buildSummary();
    Integer used = Limits.getQueryRows() - before;
    Test.stopTest();

    System.assert(used < 5000, 'Query rows still scale with data volume: ' + used);
}

Assert a tight bound like 5,000 rather than 49,999. An assertion parked next to the limit passes until the day production data grows, which is the day you needed the test to speak up.

Preventing the next one

  • Every query gets a filter that bounds its result set — an Id set, a date range, an indexed status field. “All records of this type” is not a query, it’s a table scan waiting to break.
  • Reach for COUNT() before retrieving rows to count them.
  • Treat subqueries as multipliers and estimate parent count times average children before shipping.
  • Test against production-shaped volume. A sandbox with 200 Accounts cannot tell you anything about this limit.

Frequently asked

Does a SOQL for loop raise the 50,000 limit?

No. It reduces heap consumption by processing 200 records at a time. Every retrieved record still counts against the row limit. Use it for heap errors, not this one.

Does asynchronous Apex give me more rows?

No. Async doubles the query count to 200 and heap to 12 MB, but rows stay at 50,000. Batch Apex is different: it resets the limit per execute, which is what actually lets you process millions.

Does COUNT() consume 50,000 rows if it counts 50,000 records?

No. An aggregate query returns one row, and that’s what counts. Aggregate queries are governed by their own per-transaction limit on the number of such queries.

Do rows returned to a flow or a managed package count against my limit?

Yes. The limit is per transaction and spans everything that runs in it, which is why your code can throw the exception without being the code at fault. The per-namespace breakdown in the debug log tells you which.

Can I raise the limit with a support case?

No. Per-transaction governor limits are fixed regardless of edition or licence count.


Reference: Per-Transaction Apex Limits, SOQL For Loops, Use Batch Apex, and Limits Class, Salesforce Apex Developer Guide.