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
// 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.
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:
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, 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>andString.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.
