Skip to content
← All articles

batched: chunking without the off-by-one

The hand-rolled range(0, len(xs), n) version only works on sequences — it breaks on generators, cursors and file handles, which is exactly where batching matters.

Everybody has written this:

for i in range(0, len(records), size):
    batch = records[i : i + size]
    insert_many(batch)

It is correct, the ragged tail works, and it has exactly one problem: it needs len() and slicing. So it works on a list and breaks on a generator, a database cursor, a file handle, a socket, a paginated API response — which is the entire set of situations where you actually needed to batch, because those are the ones too large to hold in memory.

Since 3.12:

from itertools import batched

for batch in batched(records, size):
    insert_many(batch)

batched(iterable, n, *, strict=False). Works on any iterable, holds one batch at a time, and the ragged final batch comes out shorter with no special case. n < 1 raises ValueError. The strict=True keyword arrived in 3.13 — on 3.12 the parameter does not exist, so code that passes it fails at runtime on the older interpreter.

Batches are tuples, and that is a typing trap

batched yields tuple[T, ...] — a variadic tuple, not a fixed-length one. So this type-checks:

for left, right in batched(values, 2):    # mypy: fine
    ...

and raises ValueError: not enough values to unpack on the final batch whenever len(values) is odd. The checker cannot help, because tuple[int, ...] genuinely might have two elements. The runtime fix is batched(values, 2, strict=True) (3.13+), which raises if the last batch is short — turning a lurking ValueError deep in your loop into an immediate, well-named failure at the source.

Contrast itertools.pairwise, which is typed Iterator[tuple[T, T]] — fixed-length, so unpacking there is arity-checked. Same-looking code, two different guarantees, and the only way to know is to have read the signature.

💡Your batch consumer needs list[str], not tuple[str, ...]. click to reveal

Where should the conversion live, and does it cost anything? Inside the generator, one batch at a time:

def bulk_pages(records: Iterable[str], size: int) -> Iterator[list[str]]:
    ...
    for batch in batched(records, size):
        yield list(batch)

The cost is one list allocation per batch — not per element — which is negligible against whatever you are batching for (a network round trip, a bulk insert). What you must not do is [list(b) for b in batched(...)], which materialises every batch and reintroduces the memory problem you adopted batched to solve.

A tuple is also the better default for the interface: it is immutable, so handing one to a consumer cannot let them mutate your buffer. Convert only where a caller genuinely needs a mutable sequence.

Validate eagerly, generate lazily

A generator function defers everything to the first next(), including argument validation:

def bulk_pages(records, size):
    if size < 1:
        raise ValueError("size must be >= 1")   # runs on first next(), not on call
    for batch in batched(records, size):
        yield list(batch)

bulk_pages(records, 0) returns a generator object quite happily. The ValueError fires later — often in a different function, several frames from the bad argument, with a traceback that points at the consumer rather than the caller. In a pipeline assembled in one place and consumed in another, that is a genuinely hard bug to read.

The fix is a plain function that validates and returns an inner generator:

def bulk_pages(records: Iterable[str], size: int) -> Iterator[list[str]]:
    if size < 1:
        raise ValueError("size must be >= 1")

    def pages() -> Iterator[list[str]]:
        for batch in batched(records, size):
            yield list(batch)

    return pages()

The outer function body runs at call time; the inner one stays lazy. The return type is identical, so no caller changes.

💡batched was added in 3.12. Before that, the standard recipe was click to reveal

zip(*[iter(it)] * n). Why is that not equivalent? Two reasons, one of which is a data-loss bug.

zip stops at the shortest input, so the ragged final batch is silently discarded. Twelve records with n = 5 gives you two batches and quietly drops two records. zip_longest pads instead, which trades data loss for sentinel values you then have to filter.

The other reason is legibility: [iter(it)] * n builds a list of n references to the same iterator, and zip round-robins between them, so each zip step pulls n consecutive items. That is a genuinely clever trick and it is precisely the sort of line that stops a reviewer for two minutes. batched(it, n) says what it does.