SfSkills Domains Docs GitHub

Home / apex / custom-iterators-and-iterables

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.

Open the skill folder on GitHub


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


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.

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.

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

SituationRecommended ApproachReason
Data comes from a single SOQL query on sObjectsDatabase.getQueryLocator()Exempted from the 50 k query-rows limit; cursor managed by platform
Records produced by Apex logic or external calloutCustom Iterable<SObject> in start()Only option when a SOQL query locator cannot represent the source
Need for-each loop over custom Apex objectsImplement Iterable<T> on the classFor-each requires Iterable<T>, not Iterator<T> alone
Total record count < 50,000 and source is pre-computedList<SObject>.iterator() wrapped in an IterableSimplest path; built-in list iterator handles cursor
Large paginated external source where count is unknownStateful custom Iterator with lazy SOQL paginationAvoids materializing full set; fetches per page on demand
Multi-object or aggregated source requiring joinsCustom Iterator that merges multiple listsQueryLocator only supports single-query sources

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


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

ArtifactDescription
Iterator<SObject> implementationApex class with hasNext() and next() carrying a lazy page buffer
Iterable<SObject> wrapperApex class returning a fresh cursor from iterator() for use in Batch start() or for-each loops
Batch Apex class using custom IterableBatch class whose start() returns the custom Iterable with correct type parameter
Decision tableGuidance on QueryLocator vs. custom Iterable vs. pre-computed List for a given scenario

Dependencies

Package files