# Custom Iterators And Iterables

> Use this skill when implementing the Iterable<T> or Iterator<T> interfaces in Apex to create custom traversal logic, build lazy-evaluation data sources for Batch Apex, or stream large result sets without materializing an entire List. Trigger keywords: custom iterator, Iterable interface, Iterator interface, batch start iterable, lazy evaluation apex, streaming apex query, paginated batch. NOT for standard list iteration (use for-each on List directly). NOT for Batch Apex fundamentals — use apex/batch-apex-patterns. NOT for Map/List bulkification in triggers or services — use apex/apex-collections-patterns.

- Skill: `pranavnagrecha/custom-iterators-and-iterables` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds add pranavnagrecha/custom-iterators-and-iterables`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pranavnagrecha/custom-iterators-and-iterables/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: PranavNagrecha (https://skillmd.com/u/pranavnagrecha)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/pranavnagrecha/custom-iterators-and-iterables

---


# Custom Iterators and Iterables

Use this skill when a Batch Apex job needs a non-SOQL data source for its `start()` method, or when you want to traverse records lazily without materializing a full `List<SObject>` in heap. It covers the `Iterator<T>` and `Iterable<T>` interfaces, their contracts, patterns for lazy evaluation, and the constraints that apply when using them inside Batch Apex.

---

## Before Starting

- Confirm whether the data source can be expressed as a single SOQL query. If it can, `Database.getQueryLocator()` is almost always the right choice because it streams results transparently and is exempt from the 50,000-row SOQL query limit. Only reach for a custom Iterable when the data source cannot be a single SOQL query.
- Understand that when `start()` returns an `Iterable`, the total records the platform can handle across all batches is bounded by the 50,000-row query-rows-returned governor limit, not the separate query locator cursor limit. This is a critical difference.
- Custom Iterators cannot be used directly in Apex for-each loops unless they are wrapped inside a class that also implements `Iterable<T>`. An `Iterator<T>` alone is not iterable by the for-each construct — the for-each loop requires `Iterable<T>`.
- Heap usage is your primary risk: if `iterator()` pre-materializes the entire collection at construction time, you have not gained anything over a plain `List`. Genuine lazy evaluation means `next()` fetches or computes one element (or a small page) at a time.

---

## Core Concepts

### The Iterator<T> Interface

`Iterator<T>` has exactly two methods: `Boolean hasNext()` and `T next()`. Both must be declared `global` or `public`. The platform calls `hasNext()` before calling `next()`; the contract is that `next()` must not be called when `hasNext()` returns `false`. Violating this contract throws a `NoSuchElementException` at runtime. The type parameter `T` is most commonly `SObject` or a concrete sObject type when used with Batch Apex, but can be any Apex type.

### The Iterable<T> Interface

`Iterable<T>` has a single method: `Iterator<T> iterator()`. When a class implements `Iterable<T>`, it can appear in an Apex for-each loop and can be returned from `Database.Batchable<T>.start()`. The `iterator()` method is called once per loop or once by the batch executor to obtain the iterator cursor. It should create and return a fresh `Iterator<T>` each time so the collection can be traversed multiple times independently.

### Batch Apex start() with Iterable

When `start(Database.BatchableContext bc)` returns `Iterable<SObject>` instead of `Database.QueryLocator`, the batch framework calls `iterator()` on the return value to enumerate all records, then chunks them into execute() batches of the configured scope size. The key behavioral difference from `QueryLocator` is that the full enumeration is subject to standard Apex governor limits, including the 50,000-row query-rows-returned cap per transaction in the `start()` execution context. The batch framework does not bypass this limit for custom iterables the way it bypasses it for `QueryLocator`.

### Lazy Evaluation Pattern

A lazy iterator defers fetching or computing the next element until `next()` is called. A common pattern is a SOQL-offset paginator: the iterator holds a current offset and page buffer. When the buffer is exhausted, `next()` triggers the next SOQL query to refill the buffer. This spreads heap usage over time but introduces additional SOQL queries. Because `start()` runs in a single transaction with its own governor limit context, the total number of rows returned across all paginated queries still accumulates against the 50,000-row query-rows-returned limit.

---

## Common Patterns

### Pattern 1 — Custom Iterable Wrapping a Pre-Computed List

**When to use:** The records to process are produced by Apex logic (e.g., aggregated results, cross-object computations, external API responses) that cannot be expressed as a SOQL query locator, but the total record count is known to stay well within the 50,000-row query-rows-returned limit.

**How it works:** Produce the `List<SObject>` in the `iterator()` method (not at construction time) and return a list-backed iterator that walks the list with an integer index.

```apex
public class ComputedAccountIterable implements Iterable<Account> {
    private final List<Id> accountIds;

    public ComputedAccountIterable(List<Id> ids) {
        this.accountIds = ids;
    }

    public Iterator<Account> iterator() {
        // Fetch at iteration time, not at construction time
        return [SELECT Id, Name FROM Account WHERE Id IN :accountIds].iterator();
    }
}
```

Note: `List<T>.iterator()` returns a built-in `Iterator<T>`, so wrapping a built-in list is zero boilerplate.

**Why not the alternative:** Passing `accountIds` as a bind variable to `Database.getQueryLocator()` is actually simpler when the IDs come from a prior Apex step. Use the custom Iterable pattern only when the data source is not a query at all — for example, records synthesized from a REST callout or an aggregation.

### Pattern 2 — Stateful Custom Iterator with Internal Cursor

**When to use:** The data source is externally paginated (e.g., records fetched from an external API), or when you need per-element transformation logic before the batch framework sees each record.

**How it works:** Implement `Iterator<SObject>` directly with an internal page buffer and a done flag.

```apex
public class PagedExternalIterator implements Iterator<Account> {
    private List<Account> buffer = new List<Account>();
    private Integer bufferIndex = 0;
    private Integer offset = 0;
    private static final Integer PAGE_SIZE = 200;
    private Boolean exhausted = false;

    public Boolean hasNext() {
        if (bufferIndex < buffer.size()) {
            return true;
        }
        if (exhausted) {
            return false;
        }
        // Refill buffer
        buffer = [SELECT Id, Name FROM Account ORDER BY CreatedDate LIMIT :PAGE_SIZE OFFSET :offset];
        bufferIndex = 0;
        offset += buffer.size();
        if (buffer.isEmpty()) {
            exhausted = true;
            return false;
        }
        return true;
    }

    public Account next() {
        if (!hasNext()) {
            throw new NoSuchElementException('No more elements');
        }
        return buffer[bufferIndex++];
    }
}

public class PagedExternalIterable implements Iterable<Account> {
    public Iterator<Account> iterator() {
        return new PagedExternalIterator();
    }
}
```

**Why not the alternative:** A single SOQL query with an OFFSET clause becomes inefficient past roughly 2,000 rows because the database must skip scanned rows. For very large sets, prefer `QueryLocator`. For moderate sets where a single SOQL query is architecturally impossible, pagination inside a custom iterator spreads the work.

---

## Decision Guidance

| Situation | Recommended Approach | Reason |
|---|---|---|
| Data comes from a single SOQL query on sObjects | `Database.getQueryLocator()` | Exempted from the 50 k query-rows limit; cursor managed by platform |
| Records produced by Apex logic or external callout | Custom `Iterable<SObject>` in `start()` | Only option when a SOQL query locator cannot represent the source |
| Need for-each loop over custom Apex objects | Implement `Iterable<T>` on the class | For-each requires `Iterable<T>`, not `Iterator<T>` alone |
| Total record count < 50,000 and source is pre-computed | `List<SObject>.iterator()` wrapped in an `Iterable` | Simplest path; built-in list iterator handles cursor |
| Large paginated external source where count is unknown | Stateful custom `Iterator` with lazy SOQL pagination | Avoids materializing full set; fetches per page on demand |
| Multi-object or aggregated source requiring joins | Custom `Iterator` that merges multiple lists | QueryLocator only supports single-query sources |

---


## Recommended Workflow

Step-by-step instructions for an AI agent or practitioner activating this skill:

1. Gather context — confirm the org edition, relevant objects, and current configuration state
2. Review official sources — check the references in this skill's well-architected.md before making changes
3. Implement or advise — apply the patterns from Core Concepts and Common Patterns sections above
4. Validate — run the skill's checker script and verify against the Review Checklist below
5. Document — record any deviations from standard patterns and update the template if needed

---

## Review Checklist

- [ ] `hasNext()` never throws; it returns `false` when exhausted
- [ ] `next()` throws `NoSuchElementException` (not returns `null`) when called past end
- [ ] `iterator()` on the `Iterable` creates a fresh cursor each call — no shared mutable state between calls
- [ ] Total rows returned through the iterator in the `start()` context stay within the 50,000-row query-rows-returned governor limit
- [ ] Heap usage has been estimated: the buffer size and object size stay within the 12 MB async heap limit
- [ ] The Batch class implements `Database.Batchable<SObject>` and `start()` return type matches the iterator element type
- [ ] If `Database.Stateful` is needed for accumulating results, it is declared on the Batch class, not the Iterator class

---

## Salesforce-Specific Gotchas

1. **Custom Iterators are subject to the 50,000-row query limit in start()** — Unlike `Database.getQueryLocator()`, which runs in a separate cursor context exempt from the query-rows-returned limit, a custom `Iterable` returned from `start()` runs inside the batch's `start()` transaction. Every SOQL row that `next()` fetches counts against the 50,000-row-per-transaction limit in that context. If your iterable pages through 60,000 records via SOQL, the job fails with `System.LimitException: Too many query rows: 50001`.

2. **Iterator<T> alone cannot be used in a for-each loop** — Apex for-each requires the target to implement `Iterable<T>`. An `Iterator<T>` by itself is not iterable. A common mistake is implementing only `Iterator<T>` and then using `for (SObject o : myIterator)`, which produces a compile error. Always pair `Iterator<T>` with a wrapping `Iterable<T>` when for-each syntax is required.

3. **Mutable state in iterator() causes ghost-replay bugs** — If the `Iterable` class stores the iterator's position as an instance variable and `iterator()` returns `this` rather than a new cursor, the second caller (or the second batch job enqueue) resumes from where the first left off instead of starting from the beginning. Always return `new MyIterator(...)` from `iterator()` rather than re-using a stored cursor.

4. **OFFSET-based pagination degrades at scale** — SOQL `OFFSET` scans and discards leading rows on every query, making it progressively slower and more CPU-intensive as the offset grows. Beyond roughly 2,000 records, use keyset pagination (`WHERE Id > :lastId ORDER BY Id`) instead of `OFFSET` inside a custom iterator.

5. **Scope size still applies; hasNext() may be called more times than expected** — The batch framework calls `hasNext()` and `next()` to fill each batch scope slice. With a scope of 200, `next()` is called 200 times before `execute()` runs. If `hasNext()` triggers a SOQL refill on every call (instead of buffering), you will exhaust SOQL-per-transaction limits quickly. Always buffer a full page before returning `true` from `hasNext()`.

---

## Output Artifacts

| Artifact | Description |
|---|---|
| Iterator<SObject> implementation | Apex class with `hasNext()` and `next()` carrying a lazy page buffer |
| Iterable<SObject> wrapper | Apex class returning a fresh cursor from `iterator()` for use in Batch start() or for-each loops |
| Batch Apex class using custom Iterable | Batch class whose `start()` returns the custom Iterable with correct type parameter |
| Decision table | Guidance on QueryLocator vs. custom Iterable vs. pre-computed List for a given scenario |

---

## Related Skills

- `batch-apex-patterns` — Covers Batch Apex structure, scope tuning, `Database.Stateful`, and `QueryLocator` patterns; use alongside this skill when designing the full batch job
- `apex-queueable-patterns` — When record volume or complexity is moderate, Queueable chaining may avoid the need for Batch + Iterable altogether

