Skip to Content
Iterators

Iterators

An iterator is an object that yields values one after another. When something is looped over with for, or when next() is called on it, an iterator is doing the work behind the scenes. Understanding iterators clarifies how looping, range(), and tools like enumerate() and zip() fit together, and why an iterator can only be consumed once.

Many built-in types (lists, tuples, strings, sets, dictionaries) are iterable: they can produce an iterator. The iterator is the object that actually steps through the values. That distinction (iterable vs iterator) is where a lot of confusion comes from.

What is an Iterator?

An iterator is an object that supports two operations:

  • __iter__ – returns the iterator itself (so iterators are also iterables).
  • __next__ – returns the next value, or raises StopIteration when there are no more items.

Nothing else is required. The for loop and built-ins like sum(), list(), and max() call these methods under the hood. An iterator moves forward only; there is no built-in way to “reset” or go back.

Lazy evaluation: Values are produced when requested, not up front. That keeps memory low for long or infinite sequences. range(0, 10**8) does not build 10⁸ integers; it produces them as you iterate.

Iterables vs Iterators

ConceptMeaning
IterableAny object that can be passed to iter() and yields an iterator.
IteratorAn object with __iter__ (returns self) and __next__ (next item or StopIteration).

Every iterator is iterable (its __iter__ returns itself). Not every iterable is an iterator: a list is iterable, but a list is not an iterator. Calling iter() on a list gives a list_iterator that steps through the list.

nums = [2, 4, 6, 8] it = iter(nums) type(it) # <class 'list_iterator'> next(it) # 2 next(it) # 4 next(it) # 6 next(it) # 8 # next(it) # StopIteration

Rule of thumb: If it can be used in for x in obj, it is iterable. If next(obj) works and advances the sequence, it is an iterator.

The Iterator Protocol

The protocol is minimal:

  1. iter(obj) calls obj.__iter__(). For iterators, __iter__ typically returns self so that the same object can be used in for loops.
  2. next(obj) (or obj.__next__()) returns the next value or raises StopIteration when done.

for is equivalent to: get an iterator with iter(), then repeatedly call next() until StopIteration is raised (the loop catches it and exits).

# What "for x in seq:" does under the hood seq = [2, 4, 6] it = iter(seq) while True: try: x = next(it) print(x) # 2, 4, 6 except StopIteration: break

Creating and Obtaining Iterators

iter()

iter(iterable) returns an iterator for the iterable. For lists, tuples, strings, sets, and dictionaries (and other built-ins), iter() returns the appropriate iterator type.

iter([2, 4, 6]) # list_iterator iter((2, 4, 6)) # tuple_iterator iter("246") # str_iterator iter({2, 4, 6}) # set_iterator iter({2: "a", 4: "b"}) # dict_keyiterator - iter(dict) iterates keys

iter() with two arguments is a different form: iter(callable, sentinel). It calls callable() with no arguments until the return value equals sentinel; then it stops. Useful for reading until a sentinel value (e.g. from a file or socket).

# Example: read until empty string (conceptual) # it = iter(lambda: input("> ").strip(), "") # for line in it: ...

next()

next(iterator) returns the next item. next(iterator, default) returns default instead of raising StopIteration when the iterator is exhausted.

it = iter([2, 4, 6, 8]) next(it) # 2 next(it) # 4 next(it, 0) # 6 next(it, 0) # 8 next(it, 0) # 0 - exhausted, default used next(it, 0) # 0 - still exhausted

Using a default avoids handling StopIteration when “no more items” is a normal case.

Consuming an Iterator

Ways to consume (and often exhaust) an iterator:

ApproachEffect
for x in it:Loop over remaining items; stops when StopIteration is raised.
list(it)Build a list from all remaining items; iterator is exhausted.
tuple(it)Build a tuple from all remaining items; iterator is exhausted.
sum(it), max(it)Consume the iterator; return one value.
next(it)Take one item; advance the iterator by one.

After an iterator is exhausted, further next() calls raise StopIteration (or return the next() default). for over the same iterator does nothing; there are no items left.

Empty iterator and aggregates: sum(iter([])) is 0 (identity for addition). But max(iter([])) and min(iter([])) raise ValueError (no default element to return). Use max(it, default=value) or min(it, default=value) to handle empty iterators safely.

it = iter([2, 4, 6, 8]) for x in it: print(x) # 2, 4, 6, 8 # it is exhausted list(it) # [] for x in it: print(x) # (nothing)

Exhaustion and One-Time Use

An iterator is a one-way, one-time stream. There is no “rewind.” Once a value is produced and consumed, it is gone for that iterator. Creating a new iterator (e.g. calling iter() again on the original iterable) starts from the beginning.

data = [2, 4, 6] it1 = iter(data) it2 = iter(data) next(it1) # 2 next(it1) # 4 next(it2) # 2 - it2 is independent, starts from the beginning next(it1) # 6 # next(it1) # StopIteration list(it2) # [4, 6] - it2 still has two items left

This is why looping twice over the same iterator does nothing the second time: the first loop exhausts it.

Built-in Iterators and Iterator-Producing Tools

Several built-ins return or work with iterators.

enumerate()

enumerate(iterable, start=0) returns an iterator of (index, item) pairs. start sets the first index (default 0).

list(enumerate([2, 4, 6])) # [(0, 2), (1, 4), (2, 6)] list(enumerate([2, 4, 6], start=2)) # [(2, 2), (3, 4), (4, 6)]

enumerate is lazy: it does not build the full list of pairs; it yields them as needed.

zip()

zip(*iterables) returns an iterator of tuples, pairing the first element of each iterable, then the second, and so on. Stops when the shortest iterable is exhausted. With one iterable, each tuple has one element. With no arguments, zip() returns an empty iterator.

list(zip([2, 4, 6], [8, 10, 12])) # [(2, 8), (4, 10), (6, 12)] list(zip([2, 4], [6, 8, 10])) # [(2, 6), (4, 8)] - stops at shortest list(zip([2, 4, 6])) # [(2,), (4,), (6,)] - one iterable → 1-tuples list(zip()) # [] - no iterables → empty list(zip([], [2, 4])) # [] - one empty → no pairs

In Python 3.10+, zip(iter1, iter2, strict=True) raises ValueError if the iterables have different lengths, so you can enforce “equal length” instead of silently stopping at the shortest.

map()

map(function, iterable, ...) returns an iterator that applies function to each element. With one iterable, function is called with one argument per element. With multiple iterables, function is called with one argument from each iterable (first from each, then second from each, etc.); iteration stops when the shortest iterable is exhausted.

list(map(lambda x: x * 2, [2, 4, 6])) # [4, 8, 12] list(map(lambda a, b: a + b, [2, 4], [6, 8])) # [8, 12] - two iterables, stops at shortest list(map(lambda a, b: a + b, [2, 4, 6], [8])) # [10] - only one pair list(map(lambda x: x * 2, [])) # [] - empty iterable → empty iterator

map() always returns an iterator (in Python 3), not a list. So len(map(f, xs)) is invalid: iterators do not support len(). Consume with list(map(...)), a loop, or next().

filter()

filter(function, iterable) returns an iterator that keeps only elements for which function(element) is truthy. If function is None, identity is used: all falsy values are removed (e.g. 0, "", None, False, []). This is a common pattern: filter(None, iterable).

list(filter(lambda x: x > 4, [2, 4, 6, 8])) # [6, 8] list(filter(None, [0, 2, "", 4, None, 6])) # [2, 4, 6] - falsy removed list(filter(lambda x: x % 2 == 0, [2, 4, 6])) # [2, 4, 6] list(filter(lambda x: x > 8, [2, 4, 6])) # [] - none pass list(filter(None, [])) # [] - empty in, empty out

Like map(), filter() returns an iterator; it is lazy and single-use.

map() and filter() vs comprehensions

All of these produce the same kind of result (a list if you wrap in list(), or an iterator). The choice is readability and whether you need a lazy iterator.

ScenarioPreferExample
Transform, single iterableList comprehension[x * 2 for x in nums] (clearer than list(map(lambda x: x * 2, nums)))
Transform, named functionmap()list(map(str.upper, words)) (reuse existing function)
Transform, multiple iterablesmap()list(map(f, xs, ys)) (comprehensions do not do parallel iteration)
Filter, simple conditionList comprehension[x for x in nums if x > 4] (clearer than list(filter(lambda x: x > 4, nums)))
Filter, drop falsy onlyfilter(None, it)list(filter(None, items)) (one call to remove None, 0, "", etc.)

When the logic is a one-line expression or condition, a comprehension is usually easier to read. Use map() or filter() when you have a named function, multiple iterables (map), or the “drop falsy” pattern (filter(None, ...)).

reversed()

reversed(sequence) returns a reverse iterator. It works on sequences that support __len__ and __getitem__ (e.g. list, tuple, str), not on arbitrary iterables. So reversed([2, 4, 6]) works; reversed(range(6)) does not in older Python (in Python 3.8+, range supports reversed).

list(reversed([2, 4, 6, 8])) # [8, 6, 4, 2] ''.join(reversed("2468")) # "8642"

any() and all()

any(iterable) returns True as soon as it finds a truthy element and stops — it does not consume the rest. all(iterable) returns False as soon as it finds a falsy element and stops. Both are short-circuit and lazy; they return True for an empty iterable (all) or False for an empty iterable (any).

any([0, 0, 2, 4]) # True - stops at 2, 4 never checked all([2, 4, 0, 6]) # False - stops at 0, 6 never checked any([]) # False - empty: no truthy element all([]) # True - empty: vacuously true any(x > 4 for x in [2, 4, 6, 8]) # True - stops at 6

Because of short-circuiting, any() and all() can partially consume an iterator: after the call, the iterator may have remaining items.

zip(*matrix) — transposing

Passing a 2D list (matrix) to zip with the * unpacking operator transposes its rows into columns. This is a very common interview pattern.

matrix = [ [2, 4, 6], [8, 10, 12], ] list(zip(*matrix)) # [(2, 8), (4, 10), (6, 12)] # rows become columns; columns become rows

itertools

The itertools module provides building blocks for working with iterators. These come up frequently in interviews.

ToolWhat it does
chain(it1, it2, ...)Concatenate iterables without building a list
chain.from_iterable(its)Flatten one level of nested iterables
islice(it, stop)Take the first stop items from an iterator (like it[:stop])
islice(it, start, stop)Take items from start to stop
zip_longest(*its, fillvalue)Like zip but pads shorter iterables with fillvalue
tee(it, n=2)Return n independent iterators from one (at a memory cost)
takewhile(pred, it)Yield items while the predicate is truthy; stop at the first falsy
dropwhile(pred, it)Skip items while the predicate is truthy; yield the rest
count(start, step)Infinite counter: start, start+step, start+2*step, …
cycle(it)Cycle through the iterable repeatedly, forever
from itertools import chain, islice, zip_longest, tee, takewhile, dropwhile list(chain([2, 4], [6, 8], [10])) # [2, 4, 6, 8, 10] list(islice(range(100), 4)) # [0, 1, 2, 3] list(zip_longest([2, 4], [6], fillvalue=0)) # [(2, 6), (4, 0)] it1, it2 = tee(iter([2, 4, 6, 8])) # two independent iterators list(it1) # [2, 4, 6, 8] list(it2) # [2, 4, 6, 8] list(takewhile(lambda x: x < 6, [2, 4, 6, 8])) # [2, 4] list(dropwhile(lambda x: x < 6, [2, 4, 6, 8])) # [6, 8]

tee() memory cost: tee() keeps a buffer of items that have been produced by the source but not yet consumed by all copies. If one copy runs far ahead of the other, memory usage grows. If both copies are consumed together, the buffer stays small.

Custom Iterators (Class-Based)

A custom iterator is a class that implements __iter__ (returning self) and __next__ (returning the next value or raising StopIteration).

Example: an iterator that yields even numbers from a start value up to a limit:

class EvensUpTo: def __init__(self, limit): self.limit = limit self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.limit: raise StopIteration value = self.current self.current += 2 return value list(EvensUpTo(10)) # [0, 2, 4, 6, 8]

__iter__ returns self so that the same object can be used in for and with next(). The state (current) lives in the object and advances with each __next__ call.

Generators as Iterators

A generator is a function that uses yield. Calling it returns a generator object, which is an iterator: it has __iter__ (returns self) and __next__ (runs the function until the next yield, then returns the yielded value). So every generator is an iterator; generators are the usual way to define custom iteration in Python without writing a class.

def evens_up_to(limit): n = 0 while n < limit: yield n n += 2 g = evens_up_to(10) next(g) # 0 next(g) # 2 list(g) # [4, 6, 8] - consumes the rest

Generator functions are lazy and one-shot, like other iterators. They are covered in more detail in their own topic; here the point is that they follow the same iterator protocol.

Infinite generators: A generator can run forever (e.g. while True: yield x). The caller controls how many values to take via next(), a loop with a break, or tools like itertools.islice. Do not pass an infinite generator to list(); it never returns.

def evens_forever(): n = 0 while True: yield n n += 2 g = evens_forever() next(g) # 0 next(g) # 2 # list(g) # never returns - infinite

Generator that yields nothing: A function that contains yield is a generator function, even if execution never reaches the yield (e.g. all code paths hit a return before yield). Calling such a generator and then calling next() raises StopIteration immediately; the generator is exhausted with zero values produced.

return in a generator: A bare return (or falling off the end) ends the generator; the next next() raises StopIteration. In Python 3.3+, return value is allowed, but the value is attached to the StopIteration instance (e.g. e.value), not returned as the next element. For normal iteration, the generator is simply “done” when it returns.

yield from

yield from iterable (Python 3.3+) is shorthand for yielding every item from another iterable or sub-generator one by one. The calling generator pauses and lets the sub-generator run until it is exhausted, then resumes. This simplifies flattening nested generators and avoids writing a manual for loop with yield.

def evens_chain(*groups): for group in groups: yield from group # equivalent to: for x in group: yield x list(evens_chain([2, 4], [6, 8], [10, 12])) # [2, 4, 6, 8, 10, 12]

With a sub-generator, yield from also passes send() values and exceptions through to the inner generator, which a plain for/yield loop cannot do. The return value of the sub-generator becomes the value of the yield from expression in the outer generator.

Generator expressions

A generator expression looks like a list comprehension but in parentheses: (expr for item in iterable) or (expr for item in iterable if condition). It returns an iterator that produces values on demand; it does not build a list. Use it when you need a one-pass, lazy sequence and do not need to index or reuse the result.

squares = (x * x for x in [2, 4, 6, 8]) next(squares) # 4 next(squares) # 16 list(squares) # [36, 64] - consumes the rest

sum(x for x in nums), max(x for x in nums), and similar calls accept a generator expression without extra parentheses when it is the only argument. Like other iterators, a generator expression is single-use; iterate again by creating a new one.

Iterators vs Containers

AspectIteratorContainer (list, tuple, set, dictionary)
StorageNo need to hold all items in memoryAll items stored
TraversalForward only, one passCan index, slice, iterate multiple times
LengthNo len() in generallen() is defined
ReuseConsume once; then exhaustedCan iterate again and again

Containers are iterables: iter(container) gives an iterator. The container stays unchanged; the iterator is what gets exhausted.

Tricky Behaviors

The following items follow the same order as the main lesson.

iter() on an iterator

iter(iterator) returns the same iterator (because __iter__ returns self). So iter(it) is it. That is why for works on iterators: for calls iter() once and then uses that object.

iter() on a non-iterable

Passing a non-iterable to iter() (e.g. iter(42) or iter(None)) raises TypeError: 'X' object is not iterable. Only objects that implement __iter__ or (for the sequence protocol) __getitem__ are iterable.

range is not an iterator

range is an iterable. iter(range(4)) returns a range_iterator. So next(range(4)) is invalid; use next(iter(range(4))) if you want to use next on a range.

next() default and exhaustion

next(it, default) returns default when StopIteration would be raised. It does not “reset” the iterator. The next call to next(it, default) will again return default until you feed the same iterator to something that expects more items (which will get none).

Exhaustion: second loop does nothing

After a for loop (or list(), sum(), etc.) over an iterator, that iterator is exhausted. A second for over the same iterator runs zero times. Create a new iterator with iter(iterable) if you need to loop again.

Passing an iterator to a function that consumes it

Functions like list(it), max(it), or sum(it) consume the iterator. If the same iterator is passed to another function or used in a loop later, it is already exhausted. Either pass the original iterable and let each caller call iter(), or pass a fresh iterator when you need one.

Using in on an iterator consumes it

The in operator works on iterators: it calls next() repeatedly until it finds a match or the iterator is exhausted. That means the iterator is partially or fully consumed. After 4 in it, the iterator has advanced past the found item (or is exhausted). Do not assume the iterator is unchanged.

it = iter([2, 4, 6, 8]) 4 in it # True - consumed 2 and 4 list(it) # [6, 8] - only the rest remains

Empty iterator and aggregates

An empty iterable produces an iterator that is exhausted immediately: next(iter([])) raises StopIteration; list(iter([])) is []. For aggregates: sum(iter([])) is 0, but max(iter([])) and min(iter([])) raise ValueError. Use max(it, default=...) or min(it, default=...) to handle empty iterators safely.

zip() stops at shortest; one iterable and empty cases

zip(*iterables) stops when the shortest iterable is exhausted; extra elements in longer iterables are ignored. With one iterable, zip([2, 4, 6]) yields 1-tuples: (2,), (4,), (6,). With no arguments, zip() returns an empty iterator. In Python 3.10+, zip(a, b, strict=True) raises ValueError if the lengths differ.

zip(*matrix) unpacks in place

zip(*matrix) works because * unpacks the list of rows as separate arguments to zip. The result is the transpose. If the rows have different lengths, zip stops at the shortest row. Use zip_longest(*matrix, fillvalue=0) to pad.

map() and filter() return iterators; no len() or indexing

map() and filter() return iterators, not lists. So len(map(f, xs)) and map(f, xs)[0] are invalid. Consume with list(...), a loop, or next(). With multiple iterables, map(f, xs, ys) stops when the shortest is exhausted.

filter(None, …) removes all falsy values

filter(None, iterable) drops all falsy values: None, 0, 0.0, "", False, empty containers. It is a common idiom. Do not confuse it with “filter out None only”; it filters out everything that is falsy.

reversed() needs a sequence

reversed() requires a sequence (or an object with __reversed__). Arbitrary iterators (e.g. from map, filter, or a generator) do not support reversed(). Convert to a list first if you need to reverse: reversed(list(it)) (but that consumes the iterator).

any() and all() partially consume an iterator

any() stops at the first truthy item; all() stops at the first falsy item. After the call, the iterator is only partially consumed (stopped wherever the short-circuit triggered). The remaining items are still in the iterator and will be produced by the next next() call or loop.

it = iter([0, 0, 2, 4, 6]) any(it) # True - consumed 0, 0, 2 and stopped list(it) # [4, 6] - the rest is still there

tee() and memory

itertools.tee(it, n) returns n independent iterators. If one copy advances far ahead of the other, the buffered intermediate values accumulate in memory. For large sequences, advancing one copy without advancing the other defeats the purpose of lazy iteration. Consume the copies together or use list(it) and slice if you need random access.

Infinite generator: do not pass to list()

A generator that never raises StopIteration (e.g. while True: yield x) is infinite. Passing it to list() never returns. Control consumption with next(), a loop with a break, or itertools.islice(g, n).

Generator that yields nothing, and return in a generator

A function that contains yield but all execution paths hit a return before reaching it is still a generator function. The first next() call raises StopIteration; no values are produced. A bare return (or falling off the end of a generator) ends it the same way. In Python 3.3+, return value is allowed, but the value is stored on the StopIteration instance as e.value; it is not yielded as the next element.

Modifying a collection while iterating over it

Iterating over a list, set, or dictionary while adding or removing elements can lead to RuntimeError (e.g. “dictionary changed size during iteration”) or undefined behavior (skipped or repeated items). The iterator does not snapshot the collection; it reflects the current state. To modify while “iterating,” iterate over a copy (e.g. for x in list(original):) or collect changes and apply them after the loop.

# BAD: can raise RuntimeError or behave oddly # d = {2: "a", 4: "b"} # for k in d: # del d[k] # OK: iterate over a copy of the keys d = {2: "a", 4: "b", 6: "c"} for k in list(d): if k == 4: del d[k] # d is {2: "a", 6: "c"}

Interview Questions

The following questions follow the same order as the main lesson.

What is the difference between an iterable and an iterator?

An iterable is any object that can be passed to iter() and yields an iterator (e.g. list, tuple, range). An iterator is an object with __iter__ (usually returning self) and __next__ (returning the next value or raising StopIteration). Every iterator is iterable; not every iterable is an iterator. A list is iterable but not an iterator; calling iter() on a list instance returns a list_iterator.

How does a for loop use the iterator protocol?

for x in obj: calls iter(obj) to get an iterator, then repeatedly calls next() on that iterator, assigning each value to x until StopIteration is raised. The loop catches StopIteration and exits.

What does iter(callable, sentinel) do?

It is the two-argument form of iter(). It repeatedly calls callable() with no arguments and yields each return value until one equals sentinel; then it stops. Used for reading or generating values until a special “stop” value appears (e.g. reading lines until a blank line).

When would you use next(it, default)?

When the iterator may be exhausted and “no more items” should be treated as a normal case. next(it, default) returns default instead of raising StopIteration, so the caller can avoid try/except. Useful when draining an iterator with a fallback value.

Why does next() on a range raise TypeError?

range is an iterable, not an iterator. next() works only on iterators. Use next(iter(range(4))) to get an iterator from the range and then call next on it.

What does iter() on a non-iterable raise?

TypeError: 'X' object is not iterable (e.g. iter(42) or iter(None)). Only objects that implement __iter__ or the sequence protocol (__getitem__ with integer indices starting at 0) are iterable.

What happens when you iterate over the same iterator twice?

The first iteration consumes the iterator. The second iteration has no items left, so the loop body does not run. To iterate again, use a new iterator: iter(iterable) or loop directly over the iterable (e.g. for x in my_list), which creates a new iterator each time.

Why might passing an iterator to multiple functions cause bugs?

Many functions (e.g. list(), sum(), max()) consume the whole iterator. The first such call exhausts it; any later use of the same iterator gets no items. Pass the original iterable so each consumer can call iter() and get its own iterator, or create a new iterator for each consumer.

Does using in on an iterator consume it?

Yes. The in operator calls next() repeatedly until the value is found or the iterator is exhausted. After 4 in it, the iterator has advanced past the found item. Elements before and including the found item are gone from the iterator; the rest are still there.

What happens when you call max() or min() on an empty iterator?

max() and min() raise ValueError: max() arg is an empty sequence (or min() arg is an empty sequence) when given an empty iterator. There is no largest or smallest value to return. Use the default parameter: max(it, default=0) or min(it, default=None) so that an empty iterator returns the default instead of raising. sum([]) is 0 (no error) because addition has an identity value.

How does enumerate() work? What does the start parameter do?

enumerate(iterable, start=0) returns a lazy iterator of (index, item) pairs. By default, indices start at 0. Passing start=n shifts all indices by n. It does not build the full list of pairs; it yields them on demand. Common use: for i, val in enumerate(items): instead of a manual counter variable.

How do you iterate over multiple sequences in parallel? What if they have different lengths?

Use zip(seq1, seq2, ...). It yields tuples of corresponding elements and stops when the shortest sequence is exhausted. Extra elements in longer sequences are ignored. To include all elements and pad shorter ones, use itertools.zip_longest. In Python 3.10+, zip(a, b, strict=True) raises if lengths differ.

Can you use len() or indexing on the result of map() or filter()?

No. map() and filter() return iterators, not lists. Iterators do not support len() or [] indexing. To get length or index, consume to a list first: list(map(f, xs)) or use a list comprehension.

What does filter(None, iterable) do?

It returns an iterator that keeps only truthy elements. All falsy values are removed: None, 0, "", False, [], etc. So list(filter(None, [0, 2, "", 4, None, 6])) gives [2, 4, 6]. It is a common way to drop falsy items in one call.

When would you use map() or filter() instead of a list comprehension?

Use map() when you have an existing function or multiple iterables (e.g. map(f, xs, ys)). Use filter() for filter(None, it) to drop falsy values, or when the predicate is already a named function. For a single iterable and a simple expression or condition, a list comprehension is often clearer. All are equivalent in what they can express; the choice is readability and whether you need a list or a lazy iterator.

Can you use reversed() on any iterable?

No. reversed() requires a sequence (or __reversed__). It works on lists, tuples, strings, and (in Python 3.8+) range. It does not work on arbitrary iterators (e.g. from map, filter, or a generator). To reverse the order of an iterator’s output, convert to a sequence first: reversed(list(it)) (this consumes the iterator).

How do any() and all() interact with iterators?

Both are lazy and short-circuit. any() stops and returns True at the first truthy item, leaving the rest of the iterator unconsumed. all() stops and returns False at the first falsy item. For an empty iterator: any([]) is False; all([]) is True. Because of early stopping, an iterator passed to any() or all() may still have items left after the call.

How would you transpose a 2D list?

Use zip(*matrix). The * unpacks the list of rows as separate arguments to zip, which then picks one element from each row in turn. Each resulting tuple is a column.

matrix = [[2, 4, 6], [8, 10, 12]] list(zip(*matrix)) # [(2, 8), (4, 10), (6, 12)]

What is itertools.chain() and when would you use it?

chain(it1, it2, ...) concatenates multiple iterables into a single iterator without building an intermediate list. Use it when you want to loop over several sequences as if they were one, without the memory cost of combining them into a list first.

What is itertools.tee() and what is its memory trade-off?

tee(it, n) returns n independent iterators that all produce the same values as the original. The trade-off: it buffers values consumed by one copy but not yet consumed by the others. If the copies are advanced in lockstep the buffer stays small; if one copy runs far ahead, memory grows. Prefer list(it) if you need to access the data multiple times.

How do you build a custom iterator?

Implement a class with __iter__ (returning self) and __next__ (returning the next value or raising StopIteration). __iter__ makes the object iterable so it can be used in for; __next__ advances internal state and yields the next item. Alternatively, use a generator function (yield) which automatically produces an iterator.

What is the difference between a class-based iterator and a generator function?

Both implement the iterator protocol. A class-based iterator requires writing __iter__ and __next__ explicitly; it is more verbose but exposes state directly and can support additional methods. A generator function is shorter and more readable: the interpreter handles __next__ and the state is captured in local variables and the execution frame. Use a class when you need fine-grained control over state or additional methods; use a generator for simple sequential output.

What is an infinite generator? How do you use one safely?

A generator that never raises StopIteration (e.g. while True: yield x) is infinite. The caller decides how many values to take: with next() in a loop, a for with a break, or itertools.islice(g, n). Do not pass an infinite generator to list(); it never returns.

What does yield from do?

yield from iterable yields each item from iterable one by one, delegating to a sub-generator. It is equivalent to for x in iterable: yield x for simple cases, but it also passes send() calls and exceptions through to the inner generator and captures the inner generator’s return value as the result of the yield from expression.

What is a generator expression? How does it differ from a list comprehension?

A generator expression has the form (expr for item in iterable) (with optional if condition). It returns an iterator that yields values on demand; it does not build a list. A list comprehension [expr for item in iterable] builds the full list in memory. Use a generator expression when you only need to iterate once and want to avoid storing all results (e.g. sum(x*x for x in nums)). Like any iterator, a generator expression is single-use.

What is lazy evaluation in the context of iterators?

Values are produced only when requested (e.g. by next() or in a for loop). The full sequence is not built in memory up front. range(10**8), map(), filter(), and zip() are lazy; list(range(10**8)) would consume the iterator and build a list of 10⁸ elements (expensive).

What happens if you modify a list or dictionary while iterating over it?

Modifying the size of a dictionary (add/delete keys) while iterating over it raises RuntimeError: dictionary changed size during iteration. For lists, modifying (e.g. removing elements) can cause skipped items or undefined behavior because the iterator uses indices. To avoid this, iterate over a copy: for x in list(original): or collect keys/indices to change and apply changes after the loop.

Last updated on