Itertools: Composable Iterator Building Blocks
1 · The lesson
readThe itertools module gives you small building blocks for loops, which you can combine like Lego pieces. Each function returns a lazy iterator that reads its input one element at a time and produces one element at a time. You can connect them (the output of one is the input of the next), and they almost never build intermediate lists. That makes them memory-efficient on huge or infinite sequences. They can also replace nested loops and accumulator variables with a single line.
Once you know the dozen functions in this lesson, you will find uses for them often.
1. Why Itertools
Three properties shared by everything in the module:
1. Lazy: nothing is computed until you iterate. itertools.count() represents 0, 1, 2, 3, … forever in constant memory.
2. Composable: chain(a, b) returns an iterator. Pass that to islice(..., 10) and you get the first ten items of the joined stream, still without building a list of either input.
3. Single-use: like all iterators, once you have iterated over them they are exhausted. Use list(iter_obj) if you need to keep the result.
import itertools as it # Lazy: nothing here is computed until you use the values stream = it.islice(it.count(start=1), 5) # iterator over [1, 2, 3, 4, 5] print(list(stream)) # [1, 2, 3, 4, 5] print(list(stream)) # [] (the iterator is already used up)
Almost everyone makes this mistake once. After iterating, the iterator is empty. Call the functions (it.count, it.islice) again to get a new iterator, or save the values in a list.
2. The Infinite Ones: count, cycle, repeat
These three iterators never stop on their own. Always use them together with islice, takewhile, zip, or something else that stops the loop.
import itertools as it # count(start=0, step=1): like range() with no end for i in it.count(start=10, step=2): if i > 20: break print(i) # 10, 12, 14, 16, 18, 20 # cycle(iter): repeats the items again and again, with no end colours = it.cycle(["red", "green", "blue"]) for _ in range(7): print(next(colours)) # red, green, blue, red, green, blue, red # repeat(x, n=infinite): the same value, n times or forever list(it.repeat("hi", 3)) # ['hi', 'hi', 'hi'] # A common pattern: number the rows of a CSV rows = ["a", "b", "c", "d"] list(zip(it.count(1), rows)) # [(1,'a'), (2,'b'), (3,'c'), (4,'d')]
zip(it.count(1), rows) gives the same result as enumerate(rows, start=1), which is the usual way to write it. But count also works where enumerate doesn't, for example with takewhile to "keep generating numbers until a condition fails".
3. chain and chain.from_iterable: Join Iterables
chain(*iterables) goes through each iterable in turn and yields its items, so it removes one level of nesting.
import itertools as it list(it.chain([1, 2], [3, 4], (5, 6))) # [1, 2, 3, 4, 5, 6] list(it.chain("ab", "cd", "ef")) # ['a', 'b', 'c', 'd', 'e', 'f']
chain.from_iterable(iter_of_iters) takes a single iterable of iterables. This is useful when you already have a list of lists:
matrix = [[1, 2, 3], [4, 5], [6, 7, 8, 9]] list(it.chain.from_iterable(matrix)) # [1, 2, 3, 4, 5, 6, 7, 8, 9] # Equivalent comprehension [x for row in matrix for x in row]
setup added so this can run · re-runs 3 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) it = _AutoMock('it') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import itertools as it except Exception: pass _pm_earlier = [ 'import itertools as it\n\n# Lazy: nothing here is computed until you use the values\nstream = it.islice(it.count(start=1), 5) # iterator over [1, 2, 3, 4, 5]\nprint(list(stream)) # [1, 2, 3, 4, 5]\nprint(list(stream)) # [] (the iterator is already used up)', 'import itertools as it\n\n# count(start=0, step=1): like range() with no end\nfor i in it.count(start=10, step=2):\n if i > 20:\n break\n print(i) # 10, 12, 14, 16, 18, 20\n\n# cycle(iter): repeats the items again and again, with no end\ncolours = it.cycle(["red", "green", "blue"])\nfor _ in range(7):\n print(next(colours)) # red, green, blue, red, green, blue, red\n\n# repeat(x, n=infinite): the same value, n times or forever\nlist(it.repeat("hi", 3)) # [\'hi\', \'hi\', \'hi\']\n\n# A common pattern: number the rows of a CSV\nrows = ["a", "b", "c", "d"]\nlist(zip(it.count(1), rows)) # [(1,\'a\'), (2,\'b\'), (3,\'c\'), (4,\'d\')]', 'import itertools as it\n\nlist(it.chain([1, 2], [3, 4], (5, 6))) # [1, 2, 3, 4, 5, 6]\nlist(it.chain("ab", "cd", "ef")) # [\'a\', \'b\', \'c\', \'d\', \'e\', \'f\']', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
For a simple list of lists, both forms work. When you are joining many streams, from_iterable is especially useful:
files = ["a.txt", "b.txt", "c.txt"] all_lines = it.chain.from_iterable(open(f) for f in files) for line in all_lines: # reads one line at a time, never all the files at once process(line)
setup added so this can run · defines process, it
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def process(*_a, **_kw): print('-> process() called') return _AutoMock('process()') it = _AutoMock('it')
4. takewhile and dropwhile: Split at the First Failure
takewhile(pred, iter) yields items while the predicate is true, then stops, even if later items would also pass.
import itertools as it nums = [1, 3, 5, 4, 6, 7, 9] list(it.takewhile(lambda n: n % 2 == 1, nums)) # [1, 3, 5] (stops at 4) list(it.dropwhile(lambda n: n % 2 == 1, nums)) # [4, 6, 7, 9] (skips items until 4, then keeps all)
dropwhile does the opposite: it skips items while the predicate is true, then yields everything from the first failure onwards (predicate is not re-checked after the first miss).
Example: remove the comment lines at the start of a file, without reading it twice:
lines = ["# header", "# notes", "data1", "data2", "# inline comment"] data = list(it.dropwhile(lambda l: l.startswith("#"), lines)) # ['data1', 'data2', '# inline comment'] (the last comment is kept!)
setup added so this can run · defines it
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) it = _AutoMock('it')
This is not the same as filter. filter checks every element. takewhile/dropwhile stop checking at the first item that fails.
5. islice: Slice Any Iterable
Lists support lst[2:10:2]. Generators don't. islice gives you slicing for any iterator:
import itertools as it # islice(iter, stop) gives the first N items list(it.islice(it.count(), 5)) # [0, 1, 2, 3, 4] # islice(iter, start, stop) gives the items in a range list(it.islice(range(20), 5, 10)) # [5, 6, 7, 8, 9] # islice(iter, start, stop, step) takes every step-th item list(it.islice(range(20), 0, 20, 3)) # [0, 3, 6, 9, 12, 15, 18] # stop=None means go on to the end list(it.islice("abcdefgh", 2, None)) # ['c', 'd', 'e', 'f', 'g', 'h']
Two limits versus list slicing:
- No negative indices: you can't ask for "the last five" items of an iterator, because it doesn't know its length.
- It consumes the underlying iterator. After
it.islice(stream, 5), the next item instreamis the sixth, not the first.
6. zip_longest: Zip Without Losing Items
Plain zip stops at the shortest iterable. zip_longest runs until the longest is exhausted, filling missing values with fillvalue.
from itertools import zip_longest names = ["Alice", "Bob", "Charlie"] scores = [90, 85] list(zip(names, scores)) # [('Alice', 90), ('Bob', 85)] (Charlie is dropped) list(zip_longest(names, scores, fillvalue=0)) # [('Alice', 90), ('Bob', 85), ('Charlie', 0)]
Use it when missing data matters: survey responses, sparse columns, partial migrations. The fillvalue is a single value applied to all missing slots in all shorter iterables.
7. groupby: Group Consecutive Items With the Same Key
groupby(iter, key=fn) walks the iterable and yields (key, group_iterator) pairs whenever the key changes. It only groups items that are next to each other. If you want one group per key, sort first.
from itertools import groupby # Already sorted by first letter, so groupby works as expected words = ["apple", "ant", "banana", "berry", "cherry"] for key, group in groupby(words, key=lambda w: w[0]): print(key, list(group)) # a ['apple', 'ant'] # b ['banana', 'berry'] # c ['cherry'] # NOT sorted, so groupby splits a group without any warning words = ["apple", "banana", "ant", "berry", "cherry"] for key, group in groupby(words, key=lambda w: w[0]): print(key, list(group)) # a ['apple'] # b ['banana'] # a ['ant'] (a separate group!) # b ['berry'] # c ['cherry']
The fix is always the same: sort by the key first.
words.sort(key=lambda w: w[0]) groups = {k: list(g) for k, g in groupby(words, key=lambda w: w[0])} # {'a': ['apple', 'ant'], 'b': ['banana', 'berry'], 'c': ['cherry']}
setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) words = _AutoMock('words') def groupby(*_a, **_kw): print('-> groupby() called') return _AutoMock('groupby()') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from itertools import groupby except Exception: pass _pm_earlier = [ 'from itertools import groupby\n\n# Already sorted by first letter, so groupby works as expected\nwords = ["apple", "ant", "banana", "berry", "cherry"]\nfor key, group in groupby(words, key=lambda w: w[0]):\n print(key, list(group))\n# a [\'apple\', \'ant\']\n# b [\'banana\', \'berry\']\n# c [\'cherry\']\n\n# NOT sorted, so groupby splits a group without any warning\nwords = ["apple", "banana", "ant", "berry", "cherry"]\nfor key, group in groupby(words, key=lambda w: w[0]):\n print(key, list(group))\n# a [\'apple\']\n# b [\'banana\']\n# a [\'ant\'] (a separate group!)\n# b [\'berry\']\n# c [\'cherry\']', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
For pure group-and-collect with no need for the order of groups, collections.defaultdict(list) (one pass, no sort) is often simpler. Use groupby when the grouping should depend on order: runs of equal pixels, events in time windows, log lines from the same request.
One more thing to watch: the inner group is an iterator that shares state with the outer groupby. Use up each group before moving to the next one (list(group) in the example). Otherwise it will be empty when you try to read it.
8. accumulate: Running Totals (and More)
accumulate(iter, func=operator.add) yields the running result.
from itertools import accumulate import operator list(accumulate([1, 2, 3, 4])) # [1, 3, 6, 10] (running sum, the default) list(accumulate([1, 2, 3, 4], operator.mul)) # [1, 2, 6, 24] (running product) list(accumulate([3, 1, 4, 1, 5, 9, 2, 6], max)) # [3, 3, 4, 4, 5, 9, 9, 9] (running maximum) # Bank balance: start with 100, then apply each day's change changes = [+50, -30, +100, -75] list(accumulate(changes, initial=100)) # [100, 150, 120, 220, 145] (needs Python 3.8+)
The initial= keyword (Python 3.8+) adds a starting value at the front. This is useful for bank balances, prefix sums starting at 0, and anything else that needs a start value.
Watch out: the function must take two arguments, (acc, x) -> new_acc, and the order of the arguments matters for non-commutative operations (like subtraction). accumulate([2, 1, 3], lambda a, b: a - b) gives [2, 1, -2], not [2, -1, -4].
9. product: Nested Loops as One Call
product(*iterables, repeat=1) is the Cartesian product: every combination of one item from each iterable.
from itertools import product # Pairs of dice rolls list(product([1, 2, 3], [1, 2, 3])) # [(1,1), (1,2), (1,3), (2,1), (2,2), (2,3), (3,1), (3,2), (3,3)] # Equivalent nested-loop form [(a, b) for a in [1, 2, 3] for b in [1, 2, 3]] # repeat=3: the Cartesian product of one iterable with itself, 3 times list(product("ab", repeat=3)) # [('a','a','a'), ('a','a','b'), ('a','b','a'), ..., ('b','b','b')] (every 3-letter string of a and b) # Three-level loop over a grid for x, y, z in product(range(3), range(3), range(3)): ... # 27 iterations, with no nested loops
product(repeat=n) is how you write "all possible passwords of length n from this alphabet" or "every coordinate in this n-dimensional grid" without an n-deep nested loop.
10. permutations and combinations
When order matters → permutations. When it doesn't → combinations.
from itertools import permutations, combinations # All orderings of 3 letters, length 2 list(permutations("abc", 2)) # [('a','b'), ('a','c'), ('b','a'), ('b','c'), ('c','a'), ('c','b')] (6 = 3*2) # All subsets of size 2. Order does not matter, so (a,b) is the same as (b,a) list(combinations("abc", 2)) # [('a','b'), ('a','c'), ('b','c')] (3 = C(3,2), "3 choose 2") # combinations_with_replacement: an element can be used more than once from itertools import combinations_with_replacement list(combinations_with_replacement("abc", 2)) # [('a','a'), ('a','b'), ('a','c'), ('b','b'), ('b','c'), ('c','c')]
Use them for combinatorial problems: subset-sum, anagram detection, brute-force searches over small spaces. Note that the size grows very fast: permutations(range(10), 10) is 3.6 million, and for range(12) it's 479 million. Use islice to look at a few results without building the whole list.
11. pairwise (Python 3.10+): Sliding Window of 2
This is a very common operation that used to need several lines of code.
from itertools import pairwise list(pairwise([1, 2, 3, 4, 5])) # [(1,2), (2,3), (3,4), (4,5)] # Find the price change from each day to the next prices = [100, 102, 99, 105, 110] deltas = [b - a for a, b in pairwise(prices)] # [2, -3, 6, 5]
For windows wider than 2, there is no built-in function. Write a generator with collections.deque(maxlen=n), or use the itertools recipes from the docs.
Common Mistakes
1. Forgetting itertools returns iterators, not lists
import itertools as it result = it.chain([1, 2], [3, 4]) print(result) # <itertools.chain object at 0x...> print(len(result)) # TypeError (iterators have no len) # Fix: make a list if you need the length, indexing, or reuse result = list(it.chain([1, 2], [3, 4]))
Don't call list() on everything by habit, because you lose the memory benefit. But when you need len, indexing, or a second iteration, make a list.
2. groupby without pre-sorting
from itertools import groupby records = [ ("Alice", "eng"), ("Bob", "sales"), ("Carol", "eng"), ] # Bug: Alice and Carol go into separate groups, because Bob is between them {k: [name for name, _ in g] for k, g in groupby(records, key=lambda r: r[1])} # {'eng': ['Carol'], 'sales': ['Bob']} (the second 'eng' group replaced the first, so Alice is lost) # Fix: sort by the same key first records.sort(key=lambda r: r[1]) {k: [name for name, _ in g] for k, g in groupby(records, key=lambda r: r[1])} # {'eng': ['Alice', 'Carol'], 'sales': ['Bob']}
Losing data without any error is the worst kind of bug. If you don't want grouping by consecutive items, use defaultdict(list) instead.
3. Iterating cycle() without a stop condition
import itertools as it for c in it.cycle("abc"): print(c) # never ends (press Ctrl+C to stop it)
Use every infinite iterator together with islice, takewhile, zip with a finite iterable, or an explicit break. Zipping two infinite iterators (zip(cycle(a), cycle(b))) also runs forever, because the shortest one is still infinite.
4. accumulate with non-commutative ops
from itertools import accumulate list(accumulate([10, 1, 2, 3], lambda a, b: a - b)) # [10, 9, 7, 4] (10, then 10-1=9, 9-2=7, 7-3=4)
Many people expect [10, 1-10, 2-9, 3-7] or something similar. That is wrong: accumulate passes (running_total, next_item), in that order. For subtraction, division, and string formatting, write a small test before you rely on your expectation.
5. Trying to slice an iterator with [:n]
import itertools as it stream = it.count() first_five = stream[:5] # TypeError (you cannot slice an iterator) # Use islice instead first_five = list(it.islice(stream, 5))
[:n] works on lists, tuples and strings, but not on iterators in general. For iterators, use islice.
🎯 Your Turn: Chunked
Write chunked(iterable, n), a generator that yields successive n-item lists from any iterable. The final chunk may be shorter than n.
list(chunked(range(10), 3)) # [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]] list(chunked("abcdefgh", 2)) # [['a', 'b'], ['c', 'd'], ['e', 'f'], ['g', 'h']] list(chunked([], 4)) # []
setup added so this can run · defines chunked
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def chunked(*_a, **_kw): print('-> chunked() called') return _AutoMock('chunked()')
Use itertools.islice on one shared iterator inside a loop. That is the standard pattern.
Skeleton:
from itertools import islice def chunked(iterable, n): # TODO 1: get an iterator over the input (iter(iterable)) # TODO 2: loop forever, pulling the next n items via islice # TODO 3: stop when islice returns an empty chunk # TODO 4: otherwise yield the chunk as a list ...
Hint 1: Sharing one iterator across calls
The key is to calliter(iterable) once to get a single iterator, then call islice(iterator, n) repeatedly on the same iterator. Each islice consumes up to n items from where the last one left off.
Hint 2: Knowing when to stop
list(islice(it, n)) returns an empty list once it is exhausted. Use while True, turn each chunk into a list, and stop (break) when the chunk is empty, before you would yield it.
Show full solution
from itertools import islice def chunked(iterable, n): """Yield successive n-sized lists from `iterable`. Final chunk may be shorter.""" if n < 1: raise ValueError("n must be at least 1") it = iter(iterable) while True: chunk = list(islice(it, n)) if not chunk: return yield chunk print(list(chunked(range(10), 3))) # [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]] print(list(chunked("abcdefgh", 2))) # [['a', 'b'], ['c', 'd'], ['e', 'f'], ['g', 'h']] print(list(chunked([], 4))) # []
Why the single it = iter(iterable)? Because islice consumes items from whatever iterator you give it. If you wrote list(islice(iterable, n)) inside the loop, every call would start a new iterator over the same list. You would get the first chunk again and again, in an infinite loop.
A one-liner using iter's two-argument form:
from itertools import islice def chunked(iterable, n): it = iter(iterable) return iter(lambda: list(islice(it, n)), [])
iter(callable, sentinel) calls callable() repeatedly and stops when it returns sentinel. Here, lambda: list(islice(it, n)) returns the next chunk, and [] (an empty list) signals exhaustion. It behaves the same, but it is harder to understand. Use the loop version in code that other people read.
Bonus: chunked is now in the standard library as itertools.batched(iterable, n), from Python 3.12 onwards. It yields tuples instead of lists, but otherwise it is the same. If you're on 3.12+:
from itertools import batched list(batched("abcdefgh", 2)) # [('a', 'b'), ('c', 'd'), ('e', 'f'), ('g', 'h')]
Writing it yourself is still a better way to learn, and your version also works on Python versions before 3.12.
What You Learned
- Itertools functions return lazy iterators: you can combine them, they save memory, and they work only once.
- The three infinite ones,
count,cycleandrepeat, always need a stop condition. chainconcatenates iterables, andchain.from_iterableremoves one level of nesting.takewhile/dropwhilesplit at the first failure, butfilterchecks every element.islicegives you slicing for any iterator (no negative indices, consumes the source).zip_longest(fillvalue=...)for uneven iterables.groupbygroups consecutive items with the same key. Sort first if you want one group per key.accumulatefor running totals/maxes/products. Watch the argument order for non-commutative operations.productreplaces n nested loops, andpermutations/combinationsfor combinatorial searches.pairwise(3.10+) for size-2 sliding windows.- Always check whether you need to
list(...)the result, because iterators are single-use.
Review: Decorators, from earlier in this path, use the same higher-order function ideas as lambdas to wrap functions with extra behaviour.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.