Fix "Apex Heap Size Too Large" in Salesforce

By Prit Sakhvala, Salesforce Developer · Updated August 2026

"Apex heap size too large" means your transaction held more than 6 MB of data in memory (12 MB async). The heap is everything your variables reference: query results, collections, strings, JSON, Blobs. The fix is holding less at once: SOQL for-loops, leaner field lists, chunked processing, or Batch Apex.

What the heap actually is

Every object your Apex code creates or references during a transaction lives on the heap: sObject lists from queries, maps you build, strings you concatenate, deserialized JSON structures, Blob contents. When the total exceeds the limit, Salesforce throws System.LimitException: Apex heap size too large, which, like all limit exceptions, cannot be caught.

Heap is shared across all namespaces in the transaction (like CPU time), so managed package allocations count against you too. See the full picture in governor limits explained.

Heap is a high-water mark, not a running total

This is the single most misunderstood property of the heap limit, and it changes how you debug. Salesforce measures the maximum amount of memory referenced at any one instant during the transaction, not the sum of everything you ever allocated.

Allocating 5 MB, letting it go out of scope, then allocating another 5 MB does not use 10 MB of heap. It peaks at 5 MB and passes. But holding both at the same time peaks at 10 MB and fails. The practical consequence: you do not need to allocate less overall, you need to hold less simultaneously. Almost every fix in this guide is an application of that one idea.

Memory is reclaimed when nothing references it any more. A variable declared inside a loop body becomes eligible for collection on the next iteration; a variable declared outside the loop stays alive for the whole loop. Where you declare something matters as much as how big it is.

Synchronous versus asynchronous budgets

ContextHeap limitNotes
Synchronous (trigger, controller, VF)6 MBShared across all namespaces
Future methods12 MBFresh heap per invocation
Queueable12 MBFresh heap per job
Batch Apex execute()12 MBFresh heap per scope chunk
Batch Apex with Database.Stateful12 MBRetained instance variables count against every chunk

That final row causes real production incidents. Database.Stateful preserves instance variables between execute() calls, so a collection you keep appending to grows across the entire batch run. The job processes the first several thousand records happily and then dies partway through. That reads like a data problem, but it's actually accumulated state. If you need stateful aggregation, store totals and counters, never lists of records.

The usual suspects

  1. Materializing big query results. List<Account> all = [SELECT ... FROM Account] on a large object loads every record and field into memory at once.
  2. Querying fields you don't use, especially long text areas and rich text fields, which can be 100 KB+ per record.
  3. String concatenation in loops. Each s += chunk creates a new string; building CSVs or HTML this way multiplies memory.
  4. JSON of large payloads. JSON.serialize/deserialize of big structures temporarily doubles the data (source + result both on heap).
  5. Blobs and attachments. Reading file bodies into Apex is the fastest way to blow 6 MB.
  6. Collections that never shrink: accumulating results across iterations instead of processing and clearing.

1. Materializing a large query result

The most common cause by a wide margin. A plain list assignment pulls every matching record and every selected field into memory simultaneously:

// ANTI-PATTERN: 50,000 records held at once List<Account> accounts = [SELECT Id, Name, Description__c FROM Account WHERE Active__c = true]; for (Account a : accounts) { process(a); }

A SOQL for-loop looks almost identical but behaves completely differently. Salesforce retrieves records in internal chunks of 200 and discards each chunk as it moves on, so peak heap stays flat regardless of how many records match:

// CORRECT: constant heap, any result size for (Account a : [SELECT Id, Name, Description__c FROM Account WHERE Active__c = true]) { process(a); }

This is the highest-value change on the page. It is a one-line edit, requires no restructuring, and often takes a transaction from failing to using a small fraction of the budget. The constraint: you cannot index into the collection or use it after the loop, because it never exists as a whole.

2. Selecting fields you never read

Heap cost scales with the width of each record, not just the row count. Long text areas are the worst offenders: a single Description or rich text field can carry 32 KB to 130 KB per record. Ten thousand records with one unused long text field is potentially hundreds of megabytes of data you are paying to hold and never look at.

Never use a broad field list out of convenience. Select exactly the fields the code touches, and audit queries in any class throwing heap errors. Removing two unused long text fields frequently resolves the error with no logic change at all.

3. String building in loops

Apex strings are immutable. Every += allocates a new string holding everything accumulated so far, leaving the previous one on the heap until collection catches up:

// ANTI-PATTERN: quadratic memory growth String csv = ''; for (Account a : accounts) { csv += a.Name + ',' + a.Id + '\n'; // new allocation every iteration } // CORRECT: one allocation at the end List<String> rows = new List<String>(); for (Account a : accounts) { rows.add(a.Name + ',' + a.Id); } String csv = String.join(rows, '\n');

Because this pattern burns CPU quadratically as well, it commonly triggers the CPU time limit in the same transaction. If you are hitting both errors at once, string building in a loop is the first thing to look for.

4. JSON serialization doubling your data

JSON.serialize() holds the source object graph and the resulting string on the heap simultaneously, so peak usage is roughly double the payload. Deserializing into an untyped Map<String, Object> is worse still: boxed objects and map overhead can inflate a payload several times over. Define a typed wrapper class containing only the fields you actually use:

// Typed deserialization: only the fields you need reach the heap public class OrderSummary { public String orderId; public Decimal total; } List<OrderSummary> orders = (List<OrderSummary>) JSON.deserialize(response.getBody(), List<OrderSummary>.class);

5. Releasing references explicitly

When a large structure is genuinely finished with but its variable remains in scope, setting it to null makes the memory eligible for reclamation before the transaction ends:

Map<Id, List<Contact>> contactsByAccount = buildLargeMap(); processContacts(contactsByAccount); contactsByAccount = null; // release before the next heavy operation

Use this deliberately rather than everywhere. It matters when two large structures would otherwise overlap in a long method, which, given heap is a high-water mark, is exactly the situation that causes the error.

Finding heap hotspots in the debug log

Three techniques, cheapest first:

1. The limits summary

LIMIT_USAGE_FOR_NS|(default)| Maximum heap size: 5892411 out of 6000000 ******* CLOSE TO LIMIT

Confirms heap is the problem and how close you are. ForceLens's Limits tab shows this as a progress bar the moment the log opens.

2. Checkpoint logging

System.debug('Heap after query: ' + Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize()); // ... suspect operation ... System.debug('Heap after transform: ' + Limits.getHeapSize());

Bracket suspect operations with these and read the USER_DEBUG lines: the jump between checkpoints is your hotspot.

3. HEAP_ALLOCATE events

With ApexCode at FINEST, the log emits HEAP_ALLOCATE events with byte counts and line numbers. Extremely detailed but extremely verbose, so combine it with a class-scoped trace flag (see debug log levels) to keep the log under the 20 MB cap.

Bisecting to the exact line

Checkpoint logging is more powerful than it first appears if you use it as a binary search rather than sprinkling debug statements at random. The procedure reliably isolates a heap spike in four or five iterations:

  1. Place a checkpoint at the start and end of the failing method. Confirm the jump between them accounts for most of the heap.
  2. Add one checkpoint at the midpoint of that method. The spike is in whichever half shows the larger increase.
  3. Bisect that half again. Repeat until you have narrowed it to a few statements.
  4. Log both Limits.getHeapSize() and the size of the suspect collection together (myList.size() alongside the heap figure tells you whether the growth is row count or row width).
System.debug(LoggingLevel.WARN, 'CHECKPOINT A: ' + Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize() + ' | rows=' + records.size());

Use LoggingLevel.WARN so your checkpoints remain visible even when ApexCode is set low, and stay easy to locate in a large log. In ForceLens the Apex Debug tab isolates these USER_DEBUG lines from everything else, so the checkpoint sequence reads as a clean list rather than something you scroll to find.

Reading heap growth across a batch job

For Batch Apex, capture logs across several execute() chunks rather than one. Heap that stays flat chunk to chunk points at a single oversized operation; heap that climbs steadily points at retained state, almost always Database.Stateful holding a collection that grows. The distinction determines the fix, and one log cannot show it to you.

Fixes that work

Which fix to reach for first

These differ enormously in effort and payoff. Work down the table: the top two rows resolve the majority of heap errors and are near-trivial edits.

SymptomFixEffortTypical impact
Heap spikes right after a queryConvert to SOQL for-loopOne lineVery high
Few records but heap still highRemove unused long text fieldsOne lineHigh
Heap climbs steadily inside a loopString.join instead of +=SmallHigh
Spike around callout handlingTyped JSON deserializationMediumMedium
Batch job fails partway throughRemove collections from Database.StatefulMediumHigh
Genuinely need all the dataMove to Batch ApexLargeDefinitive

When Batch Apex is the right answer

If a transaction genuinely must process more data than fits in 6 MB, stop optimising and change the execution model. Batch Apex gives each execute() call a completely fresh 12 MB heap, so total volume becomes irrelevant:

public class AccountCleanupBatch implements Database.Batchable<SObject> { public Database.QueryLocator start(Database.BatchableContext bc) { // QueryLocator streams up to 50 million records return Database.getQueryLocator([SELECT Id, Name FROM Account WHERE Active__c = true]); } public void execute(Database.BatchableContext bc, List<Account> scope) { // fresh 12 MB heap for every chunk for (Account a : scope) { process(a); } } public void finish(Database.BatchableContext bc) { } } // Reduce scope size if a chunk still exceeds the heap: Database.executeBatch(new AccountCleanupBatch(), 50);

The scope parameter defaults to 200. Lowering it to 50 or even 10 is the standard remedy when individual records are wide, since that's precisely the case where the default chunk size is too aggressive.

Preventing heap errors before they ship

Assert on heap in your tests. Heap problems are trivially catchable in CI if you look for them, and a single assertion prevents the regression permanently:

@isTest static void bulkProcessingStaysWithinHeap() { List<Account> accounts = TestDataFactory.createAccounts(200); insert accounts; Test.startTest(); AccountService.processAll(); Integer peakHeap = Limits.getHeapSize(); Test.stopTest(); System.assert(peakHeap < Limits.getLimitHeapSize() / 2, 'Heap exceeded 50% of budget: ' + peakHeap); }

Asserting against half the budget rather than the full limit is deliberate. A transaction at 90% passes today and fails as soon as data grows or a managed package is installed alongside it.

Default to SOQL for-loops. Treat List<X> y = [SELECT ...] as the exception requiring justification, not the default. If you do not need the collection after iterating, there is no reason to materialize it.

Remember the namespace sharing. Heap is shared across managed packages, so your code being comfortable in isolation does not mean it is safe. Leaving headroom is not defensive over-engineering: it is the only way to survive an org where you do not control everything that runs.

Frequently asked questions

What is the Apex heap size limit?

6 MB per synchronous transaction, 12 MB for asynchronous (batch, future, queueable). Exceeding it throws an uncatchable limit exception.

What fills up the heap?

Query results held in lists, big strings, JSON serialization of large payloads, Blob/file bodies, and ever-growing collections, including allocations made by managed packages in your transaction.

How do I check heap usage?

Limits.getHeapSize() checkpoints logged with System.debug; the "Maximum heap size" line in LIMIT_USAGE_FOR_NS; and HEAP_ALLOCATE events at ApexCode FINEST.

How do I fix heap errors from large queries?

Use SOQL for-loops so records process in chunks, query fewer fields, or move to Batch Apex where each chunk gets a fresh 12 MB heap.

Is heap size cumulative across the whole transaction?

No. It is a high-water mark. Salesforce measures the maximum memory referenced at any single instant, not the sum of all allocations. Allocating 5 MB, releasing it, then allocating another 5 MB peaks at 5 MB and passes. Holding both simultaneously peaks at 10 MB and fails. The goal is holding less at once, not allocating less overall.

What is the difference between a SOQL for-loop and a list assignment?

A list assignment loads every matching record into memory at once. A SOQL for-loop retrieves records in internal 200-record chunks and discards each chunk as it proceeds, so peak heap stays flat no matter how many rows match. The tradeoff is that the collection never exists as a whole, so you cannot index into it or reuse it after the loop.

Why does my Batch Apex job fail partway through?

Almost always Database.Stateful retaining a growing collection. Stateful instance variables persist across every execute() call, so a list you keep appending to accumulates across the entire run and eventually exceeds the heap. Store aggregates such as totals and counters statefully, never lists of records.

Does setting a variable to null actually free heap?

Yes, when it removes the last reference to the data. The memory becomes eligible for reclamation rather than being freed instantly, but it reliably reduces the peak when two large structures would otherwise overlap in one method. Use it deliberately at that boundary, not as a habit everywhere.

Spot heap pressure instantly

Open the log in ForceLens: the Limits tab shows heap against its budget as a progress bar, next to CPU, SOQL and DML. Free and local.

Add ForceLens to Chrome (Free)