Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Governor limits

System.LimitException: Apex heap size too large

Your transaction is holding more data in memory at once than Apex allows: 6 MB synchronous, 12 MB asynchronous. Usually it's a `List<SObject>` that grew unbounded, or a `Map` that accumulated across an iteration that was supposed to be incremental.

Also seen asApex heap size too large·heap size too large·System.LimitException: Apex heap

A Batch Apex class processes Case records and generates a JSON export for a downstream system. The class has run nightly for two years on around 200,000 cases. This quarter your support team's case volume doubled. The first batch job after the volume spike throws System.LimitException: Apex heap size too large: 12000034 and dies halfway through. The fix is on you. The downstream system needs that file in the morning.

What the platform is actually measuring

Apex executes inside a fixed-size memory budget called the heap. Synchronous code gets 6MB of heap. Asynchronous code (batch, queueable, future, scheduled) gets 12MB. Every object, string, list, map, set, blob, and SObject you hold in memory at the same time counts toward the limit.

The platform tracks heap usage continuously. When an allocation pushes total live memory above the limit, the runtime throws LimitException: Apex heap size too large with the current total. The thrown exception is unrecoverable: it tears down the current transaction, rolls back any DML done in it, and aborts the executing method.

The error message includes the byte count at the moment of failure. A reading of 12000034 means you crossed 12MB by 34 bytes. The cause isn't the last 34 bytes; it's the accumulated weight of every object you allocated and didn't release. Garbage collection happens but only on objects no longer reachable from your call stack, so anything held in a class static, an instance variable, or a method-scoped collection counts as live until the method exits.

The broken example

A Batch Apex class that builds a giant string in memory:

public class CaseExportBatch implements Database.Batchable<SObject>, Database.Stateful {
    public String accumulated = '[';

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator('SELECT Id, Subject, Description, Status FROM Case');
    }

    public void execute(Database.BatchableContext bc, List<Case> scope) {
        for (Case c : scope) {
            accumulated += '{"id":"' + c.Id + '","subject":"' + c.Subject 
                + '","desc":"' + c.Description + '","status":"' + c.Status + '"},';
        }
    }

    public void finish(Database.BatchableContext bc) {
        accumulated = accumulated.removeEnd(',') + ']';
        ContentVersion cv = new ContentVersion();
        cv.Title = 'CaseExport';
        cv.PathOnClient = 'case-export.json';
        cv.VersionData = Blob.valueOf(accumulated);
        insert cv;
    }
}

Three problems compound the heap usage:

The accumulated string is declared as an instance variable with Database.Stateful, so it persists across every execute invocation. At 200,000 cases it might be 50MB. The heap can't hold it.

String concatenation in Apex creates a new string object each time. The phrase accumulated += '...' doesn't modify the existing string; it allocates a new one with the concatenated content and discards the old one. For long strings, the cost of building it character-by-character through concatenation is much higher than the final size.

Description fields can be very long (Salesforce allows long text areas up to 32KB or more). Two hundred thousand long descriptions can weigh in at gigabytes total, far past what any heap budget can hold even if the rest of the code were optimal.

Three paths to a fix

The fixes in order of impact:

Stop accumulating across iterations. Use Batch Apex the way it was designed: each execute invocation gets its own 12MB budget. Process the chunk, write the output for that chunk, and let the data go before the next chunk arrives. Don't hold accumulated state in a Stateful instance variable unless the state is small and bounded (a counter, a summary, a list of error ids).

Stream the output instead of building it in memory. For exporting to a file, write each chunk to its own ContentVersion or send each chunk to an external endpoint. The receiving side reassembles. For aggregating data, use SOQL aggregates (SUM, COUNT, GROUP BY) on the database side rather than building the aggregate in Apex.

Reduce the data you load per record. A SOQL query that selects Description (a long text area) loads the full Description into memory for every row. If you only need the first 200 characters, select fewer fields or use a formula field that exposes a truncated version. Use SOQL FOR loops which iterate without loading all results into a list at once.

The fixed example

The same class rewritten to respect the heap:

public class CaseExportBatch implements Database.Batchable<SObject>, Database.Stateful {
    public Integer totalProcessed = 0;
    public Integer chunkCount = 0;

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator(
            'SELECT Id, Subject, Status FROM Case ORDER BY CreatedDate'
        );
    }

    public void execute(Database.BatchableContext bc, List<Case> scope) {
        // Build only this chunk's output.
        chunkCount++;
        List<String> chunkLines = new List<String>();
        for (Case c : scope) {
            chunkLines.add(JSON.serialize(new Map<String, String>{
                'id' => c.Id,
                'subject' => c.Subject,
                'status' => c.Status
            }));
        }
        String chunkBlob = '[' + String.join(chunkLines, ',') + ']';

        ContentVersion cv = new ContentVersion();
        cv.Title = 'CaseExport-Chunk-' + chunkCount;
        cv.PathOnClient = 'case-export-' + chunkCount + '.json';
        cv.VersionData = Blob.valueOf(chunkBlob);
        insert cv;

        totalProcessed += scope.size();
        // chunkLines and chunkBlob go out of scope at method end.
    }

    public void finish(Database.BatchableContext bc) {
        // Optional: emit a manifest listing all chunk files.
        System.debug('Total cases exported: ' + totalProcessed
            + ' across ' + chunkCount + ' chunks.');
    }
}

Three changes did the work. Removed the long-text Description from the SELECT clause. Built the chunk JSON using a List and String.join (which allocates the final string once instead of growing repeatedly). Wrote each chunk to its own ContentVersion. The instance variable only holds two integers across chunks.

Run this with a chunk size of 200, and each execute deals with maybe 50KB of cases. The heap stays well under budget.

Why string concatenation is the worst sin

In Apex, String is immutable. Each s = s + 'x' creates a new String object containing the concatenation, then replaces the variable's reference. The old String is collectible but only after the new one is fully constructed.

In a tight loop, you can hold both the old and new String in memory at the same time, and if the old String is still referenced anywhere else (an instance variable, another local), it isn't collected at all.

The cumulative cost of building a string by concatenation is quadratic in the number of pieces. A 100,000-character string built one character at a time touches 5 billion bytes of intermediate allocations. That's far beyond the heap budget.

The pattern that works:

List<String> pieces = new List<String>();
for (...) {
    pieces.add(somePiece);
}
String result = String.join(pieces, '');

The List holds references to each piece (cheap, each piece is small). String.join allocates the final String once with the correct total size. Heap usage is bounded by the size of the final String plus the small overhead of the List.

SOQL FOR loops and large result sets

When a query might return many rows, the standard List<X> records = [SELECT ... FROM ...] form loads everything into memory at once. For large result sets, use the FOR-loop form:

for (Case c : [SELECT Id, Subject FROM Case WHERE Status = 'Open']) {
    process(c);
    // After this iteration, c is the only Case in memory.
}

The FOR-loop variant streams rows from the database in chunks of 200. Only one chunk is in memory at a time, so the heap stays bounded regardless of how many rows the query returns (up to the 50,000-row SOQL query-rows governor).

For batches, this trick still applies within a single execute invocation. You can do additional SOQL inside execute if needed, and use FOR-loops for those sub-queries to keep memory low.

Blobs are heavyweight

Files attached to records (ContentVersion, Attachment, Document) carry their data as Blob fields. Reading the blob into memory consumes heap equal to the file size. A 2MB attachment uses 2MB of heap immediately upon cv.VersionData access.

For code that processes many files in batch, read them one at a time, process, and let them go before the next:

for (ContentVersion cv : [SELECT Id FROM ContentVersion WHERE ...]) {
    Blob data = [SELECT VersionData FROM ContentVersion WHERE Id = :cv.Id].VersionData;
    processFile(data);
    data = null;  // Hint to allow collection.
}

The explicit re-query loads each VersionData on demand instead of materializing all of them at once.

Diagnosing heap usage

Sprinkle Limits.getHeapSize() checks through your code to find the hotspot:

System.debug('Heap before query: ' + Limits.getHeapSize());
List<Case> cases = [SELECT ... FROM Case];
System.debug('Heap after query: ' + Limits.getHeapSize() + ' (' + cases.size() + ' rows)');

// ... processing ...
System.debug('Heap after processing: ' + Limits.getHeapSize());

The diffs tell you which operation allocated which proportion of the heap. The query line is usually the biggest single contributor. If it's not, your processing is the issue.

For more thorough analysis, the Apex Replay Debugger replays a debug log step by step and shows live heap state at each line.

Common heap-heavy patterns to avoid

A handful of patterns reliably cause heap pressure:

Querying related lists. SELECT Id, (SELECT Id FROM Cases) FROM Account loads all Cases for every Account into memory. For wide accounts, this is huge. Restructure to do separate queries with explicit IDs.

Caching everything in a Map. A class static Map<Id, ComplicatedObject> that you populate "just in case" grows without bound. Either cap the size or compute lookups on demand.

Long-running JSON deserialization. JSON.deserialize(huge_string, Type) consumes heap proportional to the input plus the resulting object graph. For very large payloads, use JSON.deserializeStrict with streaming patterns, or split the payload at the source.

Aggregating into nested maps. A Map<Id, Map<Id, List<Something>>> can grow geometrically with input data. Prefer flat structures or write to the database as you go.

Test patterns for heap-aware code

A heap-budget test that runs a representative workload and asserts the heap stays bounded:

@isTest
static void caseExport_staysWithinHeapBudget() {
    List<Case> cases = new List<Case>();
    for (Integer i = 0; i < 200; i++) {
        cases.add(new Case(
            Subject = 'Test ' + i,
            Status = 'New',
            Origin = 'Email'
        ));
    }
    insert cases;

    Test.startTest();
    CaseExportBatch job = new CaseExportBatch();
    Database.executeBatch(job, 200);
    Test.stopTest();

    // The test runs with a 6MB sync heap; the production batch gets 12MB.
    // If the test passes, production has 2x headroom.
    System.assert(Limits.getHeapSize() < 4 * 1024 * 1024,
        'Heap used too much: ' + Limits.getHeapSize());
}

Run this on every code change that touches the batch class.

When the heap budget genuinely isn't enough

Sometimes the work just doesn't fit in 12MB. Two architectural moves help.

Split into smaller chunks. Reduce the batch chunk size from 200 to 50 or 10. Each execute now sees fewer records and has more relative headroom. Total runtime increases but completion is more reliable.

Move to Queueable chains. Queueable Apex gets a fresh 12MB heap per job. Chaining queueables lets you process one logical chunk per job, with no carry-over heap pressure. The job graph orchestrates the work; each link has its own clean slate.

Use Platform Cache thoughtfully. Cache key-value data that's expensive to fetch but small enough to live there. Don't try to cache giant blobs or lists; the Platform Cache has its own size limits and using it as a heap-extender invites OOMs of a different kind.

Related errors

The memory family has cousins worth recognizing:

  • Apex heap size too large (the topic of this page)
  • Apex CPU time limit exceeded (you used too much time, often correlated with too much memory)
  • Too many query rows: 50001 (the SOQL fetched more than 50,000 rows in a transaction)
  • Maximum stack depth has been reached: 1001 (recursion that probably also leaks memory)

If you see two of these together, it's usually one root cause (a loop that should be bulk processing) showing up in multiple governors.

Static state and stateful batches

Database.Stateful lets your batch class persist instance variables across execute invocations. This is sometimes essential (a running counter, an error list). It's also the easiest way to blow the heap, because the persistent state never goes out of scope.

The rule: anything you put in a stateful instance variable lives for the entire batch lifetime. Keep it tiny.

If you need to accumulate data across chunks, write it to a custom object as you go and read it back in finish. The database becomes your accumulator instead of the heap.

Habits that prevent heap pain

Three habits prevent most heap-related incidents:

Inspect the heap budget consumed by every new Apex method in code review. A function that allocates a List of every Case is doing the wrong thing.

Prefer SOQL FOR-loops over List-form queries whenever the result set is unbounded. The streaming behavior keeps heap flat.

When testing, run the code against representative data volumes, not just the few records the unit test framework creates. A class that passes with 10 cases will not predict its behavior with 1,000,000 cases.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Governor limit errors