Before writing chunk, read this solved version. It splits a list into consecutive pairs. Everything chunk needs is already here — only the step size is hard-coded.
def pairs(items):
result = []
for start in range(0, len(items), 2):
result.append(items[start:start + 2])
return result
# pairs([1, 2, 3, 4, 5]) -> [[1, 2], [3, 4], [5]]
- range with a step
range(0, len(items), 2) produces 0, 2, 4, … — exactly the index each pair begins at. The third argument to range is the step, and it is the piece most people forget exists.
- slicing clamps, and that is the whole trick
On a five-item list, items[4:6] does not raise IndexError — it quietly returns the single item that exists. That is why the short final chunk needs no special handling. Indexing past the end explodes; slicing past the end does not.
- a new list, every time
A slice is always a new list. pairs never touches the list it was handed, which is why calling it twice gives the same answer twice.
Write down the index each chunk starts at for a 7-item list in chunks of 3. You get 0, 3, 6. Now ask: what in Python generates a sequence of numbers with a fixed gap between them?
range takes an optional third argument — the step. range(0, len(items), size) gives you the start index of every chunk, and nothing else. Once you have a start index, items[start:start + size] is the chunk.
Guard first: if size < 1, raise ValueError. Then build a result list; for each start in range(0, len(items), size), append items[start:start + size]; return the result. You do not need to check whether the final slice runs past the end — it cannot fail.
def chunk(items, size): if size < 1: raise ValueError("size must be at least 1") result = [] for start in range(0, len(items), ____): result.append(items[____:____]) return result
def chunk(items, size):
if size < 1:
raise ValueError(f"size must be at least 1, got {size}")
return [items[start:start + size] for start in range(0, len(items), size)]
Returning [] for a bad size would hide the caller’s mistake and let it surface somewhere far away, where it is expensive to diagnose. Raising ValueError with the offending value in the message puts the error where the bug is. This is the single most transferable habit in the exercise.
The loop version is not wrong, and on a slow day it reads more plainly. The comprehension wins because every part of it is one idea: for each start, take a slice. When a comprehension needs a condition, a nested loop and a running variable, that is the signal to go back to the explicit loop — clever is not the goal.
The short final chunk and the empty input both fall out of the design for free — slicing clamps, and range over an empty list yields nothing. Solutions that special-case them are longer and have more places to be wrong.
Every version below is correct and verified against the same cases by the build. They are here because knowing which one to reach for is the part that takes judgement, and the trade-off is stated rather than left for you to guess.
def chunk(items, size):
if size < 1:
raise ValueError(f"size must be at least 1, got {size}")
result = []
for start in range(0, len(items), size):
result.append(items[start:start + size])
return resultThree lines longer and nothing wrong with it. Once the loop body needs a condition or a second variable, this is the version that stays readable while the comprehension stops being one.
def chunk(items, size):
if size < 1:
raise ValueError(f"size must be at least 1, got {size}")
result = []
start = 0
while start < len(items):
result.append(items[start:start + size])
start += size
return resultCorrect, and it hand-rolls what range already does. Three places to get the arithmetic wrong instead of one, and forgetting the increment is an infinite loop rather than a wrong answer. Use while when the number of steps genuinely is not known in advance.
On Python 3.12 and up, itertools.batched(items, size) already does this in C — reach for it before writing your own, but note it yields tuples, not lists, and it is lazy. And if items is huge, returning a list of every chunk at once defeats the point of batching; make it a generator (see the extensions).
- Make it lazy: turn chunk into a generator with yield so a million-row file does not have to fit in memory twice.
- Accept any iterable, not just a list — a file object, a generator, a set. Slicing stops being available; itertools.islice takes over.
- Add an optional fill value that pads the final chunk to full length, defaulting to no padding. Decide what the parameter should be called and why.