Lazy iteration for memory-bounded pipelines: generator expressions, generator functions, and the itertools toolbox. Python 3.9+ unless tagged.
Contents
- Generator expressions
- Feeding reducers: sum, any, all, max, min, join
- Generator functions and yield from
- Laziness pitfalls
- Streaming large files
- itertools reference
- Batching
- Windows and pairs
- groupby
- accumulate, product, combinations
- Recipes
- Choosing list vs generator
Generator expressions
Same syntax as a list comprehension with parentheses. Nothing runs until something iterates it; it produces one item at a time and can be consumed once.
squares = (x * x for x in range(10**9)) # instant, O(1) memory
next(squares) # 0
next(squares) # 1When the generator is the only argument of a call, the call's parentheses are enough:
total = sum(len(t) for t in texts)
total = sum((len(t) for t in texts), start=0) # extra parentheses needed with a second argumentThe first iterable is evaluated immediately; everything else is deferred:
gen = (x / norm for x in vectors) # `vectors` evaluated now
norm = 2.0 # `norm` looked up lazily: changes the result!Feeding reducers: sum, any, all, max, min, join
| Call | Prefer | Why |
|---|---|---|
sum([f(x) for x in xs]) | sum(f(x) for x in xs) | no temporary list |
any([p(x) for x in xs]) | any(p(x) for x in xs) | stops at first True, skips the rest of p calls |
all([p(x) for x in xs]) | all(p(x) for x in xs) | stops at first False |
max([f(x) for x in xs]) | max(f(x) for x in xs) or max(xs, key=f) | key= returns the item, not the score |
len([x for x in xs if p(x)]) | sum(1 for x in xs if p(x)) | count without storing |
[x for x in xs if p(x)][0] | next((x for x in xs if p(x)), None) | stops at first match |
"".join([...]) | either | join builds a list internally; the list form is slightly faster |
sorted([...]) | either | sorted materialises anyway |
Short-circuiting matters when each check is expensive, for example calling a moderation model:
def is_safe(text: str) -> bool:
return moderation_client.classify(text).safe # network call
ok = all(is_safe(t) for t in outputs) # stops at the first unsafe text
ok = all([is_safe(t) for t in outputs]) # classifies everything firstEmpty inputs: max()/min() of an empty iterable raise ValueError; pass default=:
best = max((h.score for h in hits), default=0.0)
top_hit = max(hits, key=lambda h: h.score, default=None)sum of floats accumulates rounding error; math.fsum is exact (and in 3.12 sum itself uses compensated summation for floats).
Generator functions and yield from
A function containing yield returns a generator. Use one when the logic needs statements (try/except, several steps, state).
from typing import Iterator, Iterable
def clean_lines(lines: Iterable[str]) -> Iterator[str]:
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
yield lineyield from delegates to another iterable (and forwards send/throw to sub-generators):
def walk_messages(conversations):
for conv in conversations:
yield from conv["messages"]
def flatten(obj):
"""Recursively flatten nested lists/tuples (arbitrary depth)."""
if isinstance(obj, (list, tuple)):
for item in obj:
yield from flatten(item)
else:
yield obj
list(flatten([1, [2, [3, [4]]], (5,)])) # [1, 2, 3, 4, 5]Generator pipelines compose like Unix pipes; each stage holds one item:
lines = read_lines("corpus.txt")
cleaned = clean_lines(lines)
chunks = (c for line in cleaned for c in split_into_chunks(line, 512))
for chunk in chunks:
index.add(chunk)Type hints: Iterator[T] for simple generators; Generator[Yield, Send, Return] when you use send or return values. In 3.13 the last two parameters of Generator default to None, so Generator[str] is valid.
Laziness pitfalls
| Pitfall | Example | Fix |
|---|---|---|
| consumed once | g = (x for x in xs); list(g); list(g) gives [] the second time | make a list, or a function returning a new generator |
| truthiness | if g: is always True | first = next(g, None) or materialise |
no len() / indexing | len(g), g[0] raise TypeError | sum(1 for _ in g), next(islice(g, i, None)) |
| closed resource | returning (l for l in fh) from inside with open(...) | yield inside the with, or read eagerly |
| late lookup of free variables | norm changed before iteration (see above) | bind as default arg or materialise |
| exceptions raised at consumption time | errors appear far from the definition | validate inputs eagerly before returning the generator |
StopIteration inside a generator | becomes RuntimeError (PEP 479, 3.7+) | use next(it, default) or return |
Closed file example:
def bad(path):
with open(path) as fh:
return (line.strip() for line in fh) # file closes on return
def good(path):
with open(path) as fh:
for line in fh:
yield line.strip() # file stays open while iteratingConsuming a generator partially and abandoning it keeps the file open until the generator is garbage-collected. Use contextlib.closing(gen) or exhaust it if you need deterministic cleanup.
Streaming large files
Never fh.read().splitlines() or json.load a multi-GB file. Iterate.
import json
def read_jsonl(path):
with open(path, encoding="utf-8") as fh:
for lineno, line in enumerate(fh, 1):
if line.strip():
try:
yield json.loads(line)
except json.JSONDecodeError as e:
raise ValueError(f"{path}:{lineno}: {e}") from e
n_long = sum(1 for rec in read_jsonl("train.jsonl") if len(rec["text"]) > 4000)Fixed-size binary chunks with the two-argument iter:
from functools import partial
def file_chunks(path, size=1 << 20):
with open(path, "rb") as fh:
yield from iter(partial(fh.read, size), b"")CSV rows lazily: csv.DictReader(fh) is already an iterator. Gzip: gzip.open(path, "rt") iterates lines the same way.
itertools reference
| Function | What it yields | Typical use |
|---|---|---|
count(start, step) | infinite counter | ids, with zip |
cycle(it) | repeats forever (stores a copy) | round-robin API keys |
repeat(x, n) | x n times | padding, map(pow, xs, repeat(2)) |
accumulate(it, func, initial=) | running reduction | cumulative tokens, offsets |
batched(it, n) 3.12+ | tuples of size n (last may be short) | embedding / API batches |
batched(it, n, strict=True) 3.13+ | raises if last batch is short | fixed-shape tensors |
chain(*its) / chain.from_iterable(its) | concatenation / flatten one level | merge result lists |
compress(data, selectors) | items where selector is truthy | apply a boolean mask |
dropwhile(pred, it) | skip while pred true, then everything | skip a header |
takewhile(pred, it) | yield while pred true, then stop | stop at a sentinel |
filterfalse(pred, it) | items where pred is false | complement of filter |
groupby(it, key) | (key, group iterator) per consecutive run | sorted grouping |
islice(it, start, stop, step) | slice of any iterator | first N, skip N, sample every k |
pairwise(it) 3.10+ | overlapping pairs | deltas, bigrams |
starmap(func, it) | func(*args) per tuple | apply to zipped args |
tee(it, n) | n independent iterators (buffers) | two passes over a stream |
zip_longest(*its, fillvalue=) | zip to the longest | align ragged lists |
product(*its, repeat=) | cartesian product | hyper-parameter grids |
permutations(it, r) | ordered arrangements | tests |
combinations(it, r) | unordered subsets | pairwise similarity |
combinations_with_replacement(it, r) | subsets with repeats | rarely |
Batching
from itertools import islice
def batched(iterable, n):
"""Backport of itertools.batched (3.12+) for Python 3.9-3.11."""
if n < 1:
raise ValueError("n must be at least one")
it = iter(iterable)
while batch := tuple(islice(it, n)):
yield batch
list(batched("ABCDEFG", 3)) # [('A', 'B', 'C'), ('D', 'E', 'F'), ('G',)]Use it for embedding requests, bulk vector-store upserts and DB writes:
for batch in batched(chunks, 100):
vector_store.upsert(
ids=[c.id for c in batch],
vectors=embed([c.text for c in batch]),
metadata=[c.metadata for c in batch],
)Batch by token budget instead of count (variable-size batches) needs state, so it is a generator function:
def batch_by_tokens(texts, count_tokens, max_tokens=8000, max_items=256):
batch, used = [], 0
for text in texts:
n = count_tokens(text)
if batch and (used + n > max_tokens or len(batch) == max_items):
yield batch
batch, used = [], 0
batch.append(text)
used += n
if batch:
yield batchSlicing a list into batches when you already have a list is also fine:
batches = [items[i:i + size] for i in range(0, len(items), size)]Windows and pairs
from itertools import pairwise, islice
from collections import deque
deltas = [b - a for a, b in pairwise(timestamps)] # 3.10+
bigrams = list(pairwise(tokens))
def sliding_window(iterable, n):
"""Overlapping windows of size n: ABCDE, 3 -> ABC BCD CDE."""
it = iter(iterable)
window = deque(islice(it, n - 1), maxlen=n)
for x in it:
window.append(x)
yield tuple(window)Overlapping text chunks (chunk size and overlap in tokens):
def overlapping_chunks(tokens, size=512, overlap=64):
step = size - overlap
return [tokens[i:i + size] for i in range(0, max(len(tokens) - overlap, 1), step)]Pre-3.10 pairwise: zip(xs, xs[1:]) for sequences, or a, b = tee(it); next(b, None); zip(a, b) for iterators.
groupby
groupby groups consecutive equal keys. Sort by the same key first, unless the data is already ordered (log lines per request, streaming tokens per message).
from itertools import groupby
from operator import itemgetter
rows = sorted(results, key=itemgetter("model"))
for model, group in groupby(rows, key=itemgetter("model")):
scores = [r["score"] for r in group] # consume before the next iteration
print(model, sum(scores) / len(scores))The group iterator is invalidated when groupby advances; materialise it if you need it later. If you cannot or do not want to sort, use defaultdict(list) (one pass, O(n)).
Run-length encoding:
rle = [(k, sum(1 for _ in g)) for k, g in groupby("aaabccdd")] # [('a', 3), ('b', 1), ('c', 2), ('d', 2)]accumulate, product, combinations
from itertools import accumulate, product, combinations
import operator
token_counts = [120, 340, 90, 410]
offsets = list(accumulate(token_counts, initial=0)) # [0, 120, 460, 550, 960]
running_max = list(accumulate(losses, min)) # best loss so far
# keep messages until the context budget is exhausted
from itertools import takewhile
kept = [m for m, total in takewhile(lambda p: p[1] <= budget,
zip(messages, accumulate(len_tokens(m) for m in messages)))]
# hyper-parameter grid
grid = [
{"lr": lr, "batch_size": bs, "warmup": w}
for lr, bs, w in product([1e-5, 3e-5], [16, 32], [0, 500])
]
# all unordered pairs for a similarity matrix (n*(n-1)/2 pairs)
pairs = [(a.id, b.id, cosine(a.vec, b.vec)) for a, b in combinations(docs, 2)]product evaluates its inputs eagerly into tuples, so infinite iterators do not work with it.
Recipes
from itertools import chain, islice, zip_longest, tee, filterfalse
from collections import deque
def take(n, iterable):
return list(islice(iterable, n))
def first(iterable, default=None):
return next(iter(iterable), default)
def consume(iterator):
deque(iterator, maxlen=0)
def partition(pred, iterable):
"""Split into (false_items, true_items) iterators."""
t1, t2 = tee(iterable)
return filterfalse(pred, t1), filter(pred, t2)
def unique_everseen(iterable, key=None):
seen = set()
for item in iterable:
k = item if key is None else key(item)
if k not in seen:
seen.add(k)
yield item
def roundrobin(*iterables):
sentinel = object()
for group in zip_longest(*iterables, fillvalue=sentinel):
yield from (x for x in group if x is not sentinel)partition with tee buffers items consumed by one side and not the other; when you need both lists, a single loop with two appends is simpler and uses one pass:
passed, failed = [], []
for case in eval_cases:
(passed if case.score >= 0.8 else failed).append(case)Choosing list vs generator
| Situation | Choose |
|---|---|
| iterate once, large or unbounded input | generator |
need len, indexing, slicing, multiple passes | list |
| result returned from a public API | list (or document that it is lazy) |
passing to sum/any/all/min/max | generator |
passing to "".join, sorted, np.array | list is fine (they materialise anyway) |
inside a with block that closes a resource | list, or yield inside the block |
| debugging | list (you can print it) |
| feeding an async pipeline / streaming response | async generator (async def ... yield) |
python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.
python-exception-handling-designer
Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.
python-idiomatic-code-reviewer
Review Python code for non-idiomatic, unpythonic or risky patterns and rewrite it into clean modern Python: PEP 8 / PEP 20, EAFP vs LBYL, iteration idioms (enumerate, zip, comprehensions, any/all), unpacking, f-strings, pathlib, truthiness, `is None`, mutable defaults, walrus, match statements, collections / itertools / functools, logging instead of print, with-blocks and resource handling. Includes AI-engineering checks: hardcoded API keys, time.sleep or requests inside async def, missing HTTP timeouts, unbounded retry loops, model clients built at import time, prompt string concatenation, sequential awaits, eval() on LLM output. Ships a 46-rule AST reviewer with severities and suggested rewrites.
python-type-hints-annotator
Add accurate, modern type hints to Python and fix mypy / pyright (pyrefly, ty) errors: builtin generics, X | None, TypeAlias and the 3.12 type statement, TypeVar and PEP 695 generics, Protocol, Callable / ParamSpec / Concatenate, TypedDict (Required, NotRequired, ReadOnly), Literal, Final, ClassVar, overload, Annotated, TypeGuard / TypeIs, Self, Never, TypeVarTuple, async types, Iterable vs Sequence vs Mapping, TYPE_CHECKING, stubs, py.typed, gradual strictness. AI-engineering focus: typed LLM message dicts and tool schemas, pydantic vs TypedDict, numpy NDArray, torch / jaxtyping shapes, streaming generators, async clients, callbacks, generic VectorStore protocols, typed config. Ships an AST audit: coverage gate, legacy-typing rewrites, inferred types.