---
title: "Heap: SOQL for-loop against list assignment"
url: "https://trailassembly.com/heap-soql-for-loop/"
topic: "Apex and Development"
published: "2026-09-15T13:56:02+00:00"
updated: "2026-10-06T13:56:06+00:00"
---

# Heap: SOQL for-loop against list assignment

**A SOQL for-loop pulls records in batches of 200 and lets each batch go out of scope, so heap stays roughly constant no matter how many rows you process.** List assignment holds every row at once. Use the for-loop when the result set is large and you do not need it all in memory simultaneously, and list assignment when you do.

## The two forms

```apex
// List assignment: all rows on the heap at once
List<Account> accounts = [SELECT Id, Name, Description FROM Account];
for (Account a : accounts) { process(a); }

// SOQL for-loop, single variable: 200 at a time internally
for (Account a : [SELECT Id, Name, Description FROM Account]) {
    process(a);
}

// SOQL for-loop, list variable: an explicit 200-record chunk
for (List<Account> chunk : [SELECT Id, Name FROM Account]) {
    for (Account a : chunk) { process(a); }
}
```

The third form is the useful one that gets ignored. It gives you a list of up to 200 records per iteration, which is exactly the right shape for doing DML on each chunk.

## What the heap ceiling actually is

6MB synchronous, 12MB asynchronous. That sounds generous until you count what a record costs. A narrow query of Id and Name on an SObject is on the order of a couple of hundred bytes per row once overhead is included. Add a long text area field, or twenty fields, and a row can be several kilobytes.

The consequence: 50,000 rows of a two-field query is usually fine on the heap. 50,000 rows of a twenty-field query including a description field is not. The query row limit is 50,000 and the heap limit will stop you first on any wide query.

## Measuring the difference

Do not reason about it, read it.

```apex
Integer before = Limits.getHeapSize();
List<Account> accounts = [SELECT Id, Name, Description FROM Account LIMIT 10000];
System.debug(LoggingLevel.ERROR, 'list heap: ' +
    (Limits.getHeapSize() - before) + ' of ' + Limits.getLimitHeapSize());
```

One caveat on the reading: heap is current occupancy, so it falls when objects become collectable and a delta can come out lower than expected. A list declared outside the loop keeps its contents alive, which defeats the whole point of the for-loop.

Run the same block with the for-loop form and compare. The list version grows with the row count. The for-loop version stays close to flat, because each batch of 200 becomes unreachable when the next one loads.

## When list assignment is right

- **You need `size()` before iterating,** to branch on the count or to pre-allocate.

- **You need a second pass** over the same records. A SOQL for-loop is consumed once. Re-querying to iterate again costs another query.

- **You need to sort the whole set,** which requires all of it in memory.

- **The result set is genuinely small.** Selecting one account by Id into a for-loop is noise for the reader.

- **You are building a Map** from the results, because the Map holds everything anyway and the for-loop saves you nothing.

That last point matters more than it looks. If your loop body adds every record to a collection you keep, the heap cost is the same as list assignment plus the loop machinery. The for-loop only helps when records are processed and discarded.

## Chunked DML inside a for-loop

The list-variable form makes this natural:

```apex
for (List<Contact> chunk : [
        SELECT Id, Status__c FROM Contact WHERE Status__c = 'Pending']) {
    for (Contact c : chunk) { c.Status__c = 'Reviewed'; }
    update chunk;
}
```

Each iteration holds 200 contacts and one DML statement. The heap stays flat. Watch the counters though: 150 DML statements and 10,000 DML rows per transaction means this pattern caps out at 10,000 records, and 50 chunks uses a third of your DML statements. Past that volume the answer is [batch Apex](https://trailassembly.com/apex-batch-that-scales/), not a cleverer loop.

## Other ways to drop heap

- **Query fewer fields.** The single biggest lever, and the one nobody pulls. `SELECT *` habits from other databases do real damage here.

- **Null out large variables** when you are finished with them, so they become collectable.

- **Avoid string concatenation in loops.** Each concatenation creates a new string. Use a `List<String>` and `String.join`.

- **Move to async** for double the ceiling, when the work does not need to be synchronous.

Measured in a Summer ’26 scratch org on 14 September 2026, querying five fields across 166 Accounts. Assigning to a `List<Account>` added **10,739 bytes** of heap. The identical query as a SOQL for-loop added **162 bytes**, because only one chunk is resident at a time. Same rows, same fields, a 66-fold difference in what the transaction was holding.

## Frequently asked

**Does a SOQL for-loop use fewer query rows?** No. Query rows are counted the same way, against the 50,000 limit. Only heap differs.

**Is the internal batch size configurable?** No, it is 200 and not adjustable.

**Can I do DML on the single-record form?** You can, and you should not. That is DML inside a loop, one statement per record, and you will hit 150 quickly. Use the list-variable form.

---

Source: https://trailassembly.com/heap-soql-for-loop/
Trail Assembly — independent Salesforce write-ups.
