Python range(), enumerate() & zip()
The three loop helpers you'll use every day
A complete, runnable reference for Python's three most-used built-in iteration helpers. Every code block includes a numbered line-by-line breakdown, real-world patterns, edge cases, and practice exercises.
names = ["Ana", "Bob", "Cara"] scores = [95, 88, 92] for i in range(3): print(i) for i, name in enumerate(names, start=1): print(f"{i}. {name}") for name, score in zip(names, scores): print(f"{name}: {score}")
📚 What This Lesson Covers
Ten sections, each with runnable code and a numbered explanation of every line
🎯 Why These Three Functions?
Almost every non-trivial Python loop uses one of them
range(), enumerate(), and zip() are built into Python and available without any import. They turn manual index-and-counter loops into clean, readable code. If you find yourself maintaining a separate counter variable or an index i that you increment by hand, there is almost certainly a better way using one of these three.
What each one does, in one line
range(): generate a sequence of numbers.
enumerate(): iterate with an index alongside the value.
zip(): iterate over multiple iterables in parallel.
list() if you need to iterate multiple times or inspect the values.🔢 range() — Generate a Sequence of Numbers
The canonical way to loop a specific number of times, or generate an arithmetic sequence
📏 Three Forms of range()
range() has three forms: range(stop), range(start, stop), and range(start, stop, step). In all cases, the stop value is excluded.
print(list(range(5))) print(list(range(2, 8))) print(list(range(0, 20, 3))) print(list(range(10, 0, -2))) print(list(range(5, 5))) print(list(range(5, 2)))
- 1
range(5)— single argument. Starts at 0, stops before 5, step 1. Result:[0, 1, 2, 3, 4]. - 2
range(2, 8)— start at 2, stop before 8. Result:[2, 3, 4, 5, 6, 7]. - 3
range(0, 20, 3)— step by 3. Result:[0, 3, 6, 9, 12, 15, 18]. - 4
range(10, 0, -2)— negative step counts downward. Result:[10, 8, 6, 4, 2]. - 5
range(5, 5)— start equals stop. Result:[](empty). No values are produced. - 6
range(5, 2)— start greater than stop with positive step. Result:[](empty). Python doesn't automatically count down — you must use a negative step.
range(5, 2) returning an empty sequence is the most common surprise for beginners — but it's consistent: range(start, stop, step) yields values only while the condition start < stop (positive step) or start > stop (negative step) holds.🔄 Using range() in a for Loop
The most common use of range() is simply to repeat an action N times — no list is ever built, and for huge ranges the memory cost stays constant.
for i in range(5): print(f"Iteration {i}") for i in range(10, 0, -1): print(i, end=" ") print() total = 0 for i in range(1, 11): total += i print(f"Sum 1..10 = {total}")
- 1–2Loop over
range(5)—itakes values 0, 1, 2, 3, 4. Five iterations, each printing a message. - 4–5Loop over
range(10, 0, -1)— counts down from 10 to 1.end=" "prints on the same line with a space separator. - 6
print()with no arguments prints a newline, ending the line of numbers. - 8Initializes an accumulator.
- 9–10Loop over
range(1, 11)— 1 through 10 inclusive. The upper bound 11 is excluded, which is why this sums 1 to 10. - 11Prints the total:
55.
sum(): sum(range(1, 11)) returns 55 without a manual loop. Manual loops are only needed when the body does more than accumulate.🧮 range() — Length, Indexing, and Membership
A range object supports many of the operations you'd expect from a sequence — without storing the values in memory.
r = range(1, 10, 2) print(r) print(len(r)) print(r[0]) print(r[-1]) print(5 in r) print(4 in r) print(sum(r))
- 1
range(1, 10, 2)— 1, 3, 5, 7, 9. Five values. - 3Prints the
rangeobject representation:range(1, 10, 2). This is the whole object — not the expanded list. - 4
len(r)works in O(1) — the length is computed mathematically, not by counting. Result:5. - 5Indexing gives the first element:
1. - 6Negative indexing gives the last element:
9. - 7Membership test —
5 in risTruebecause 5 appears in the sequence. - 8
4 in risFalse— 4 is not in the sequence (odd numbers only). - 9
sum(r)adds all values: 1 + 3 + 5 + 7 + 9 =25.
len(range(10**12)) returns instantly because range computes its length from the start, stop, and step — it never expands the sequence. This is why range scales to enormous values without memory cost.📋 enumerate() — Iterate with an Index
The clean way to loop over items and know their positions — without a manual counter
🔢 Basic enumerate()
enumerate() wraps an iterable and yields (index, value) pairs. The index starts at 0 by default.
fruits = ["apple", "banana", "cherry"] for i, fruit in enumerate(fruits): print(f"{i}: {fruit}") for i, fruit in enumerate(fruits, start=1): print(f"{i}. {fruit}") print(list(enumerate(fruits))) print(list(enumerate(fruits, start=100)))
- 1A three-item list.
- 3–4
enumerate(fruits)yields(0, 'apple'),(1, 'banana'),(2, 'cherry'). The loop unpacks each pair intoiandfruit. - 6–7
start=1shifts the index to begin at 1 — useful for user-facing numbered lists. - 9Materializes the enumerate object into a list of tuples:
[(0, 'apple'), (1, 'banana'), (2, 'cherry')]. - 10
start=100begins counting from 100. Useful for batch numbering or pagination.
enumerate() is the correct fix for the anti-pattern for i in range(len(items)): item = items[i]. That version is slower, more verbose, and fails if items isn't a sequence (e.g. a set or generator).🧭 enumerate() with Other Iterables
Enumerate works with anything iterable — not just lists. Strings, dicts, sets, files, and generators all work.
word = "hi" scores = {"Ana": 95, "Bob": 88} for i, ch in enumerate(word): print(f"char {i}: {ch}") for i, (name, score) in enumerate(scores.items(), start=1): print(f"{i}. {name}: {score}") for i, ch in enumerate("abc", start=-1): print(f"i={i}, ch={ch}")
- 1A short string.
- 2A dict with two entries.
- 4–5Enumerating a string iterates character by character.
'hi'produces(0, 'h'),(1, 'i'). - 7–8Enumerating dict items.
scores.items()yields('Ana', 95)tuples; the outer unpacking splits the enumerate index from the pair, and the inner parentheses unpack the key and value. - 10–11
start=-1starts the index at negative one. Unusual but legal — useful for countdown-style numbering.
scores.items() when you want both.📄 enumerate() — Real-World: Numbered Report
A classic use case: turn a list of records into a numbered report where the number is meaningful to the reader.
tasks = ["Write docs", "Review PR", "Deploy"] lines = [] for i, task in enumerate(tasks, start=1): status = "pending" if i < 3 else "in progress" lines.append(f"{i}. {task} ({status})") print("\n".join(lines))
- 1A list of three task descriptions.
- 3Accumulator for the formatted lines.
- 4–6
enumerate(tasks, start=1)numbers each task starting from 1. A conditional expression assigns a status based on position. - 7Joins all lines with newlines for a single multi-line output.
🔗 zip() — Iterate in Parallel
Combine multiple iterables element by element — the elegant fix for parallel-list problems
🔗 Basic zip()
zip() takes any number of iterables and yields tuples of corresponding elements. It stops when the shortest iterable runs out.
names = ["Ana", "Bob", "Cara"] scores = [95, 88, 92] for name, score in zip(names, scores): print(f"{name}: {score}") print(list(zip(names, scores))) print(list(zip([1, 2], "ab", ["x", "y"])))
- 1–2Two equal-length lists.
- 4–5
zip(names, scores)yields('Ana', 95),('Bob', 88),('Cara', 92). The loop unpacks each pair. - 7
list(zip(...))materializes the pairs:[('Ana', 95), ('Bob', 88), ('Cara', 92)]. - 8Three-way zip — any number of iterables can be combined. Result:
[(1, 'a', 'x'), (2, 'b', 'y')].
zip() stops at the shortest input. If names has 3 items and scores has 2, only two pairs are produced — the extra name is silently dropped. If you need to keep the longer list, use itertools.zip_longest().🔗 zip() with Different-Length Iterables
The default zip() behaviour is "shortest wins." itertools.zip_longest changes that to "longest wins," padding with a fill value.
from itertools import zip_longest a = [1, 2, 3, 4] b = ["a", "b"] print(list(zip(a, b))) print(list(zip_longest(a, b))) print(list(zip_longest(a, b, fillvalue="-")))
- 1Imports
zip_longestfrom the standard library'sitertoolsmodule. Nothing to install. - 3A four-item list.
- 4A two-item list.
- 6
zip(a, b)stops whenbruns out. Result:[(1, 'a'), (2, 'b')]. - 7
zip_longestcontinues to four pairs, filling missing values withNone. Result:[(1, 'a'), (2, 'b'), (3, None), (4, None)]. - 8
fillvalue="-"overrides the default. Result:[(1, 'a'), (2, 'b'), (3, '-'), (4, '-')].
zip_longest when you want to keep all data — for example, when merging two dicts where one may have extra keys, or comparing ragged datasets where missing values matter.🔗 Building Dictionaries with zip()
The single most common use of zip() in real code: building a dict from two parallel lists. dict() accepts the zip's (key, value) pairs directly.
keys = ["name", "age", "city"] values = ["Ana", 25, "Manila"] user = dict(zip(keys, values)) print(user) headers = ["id", "score"] rows = [(1, 95), (2, 88)] for row in rows: record = dict(zip(headers, row)) print(record)
- 1–2Two parallel lists: keys and their corresponding values.
- 4
zip(keys, values)produces('name', 'Ana'),('age', 25),('city', 'Manila').dict()consumes those pairs to build the dict. - 5Prints the resulting dict:
{'name': 'Ana', 'age': 25, 'city': 'Manila'}. - 7–8Column headers and rows — the shape you get from a CSV or database query.
- 10–12For each row,
zipwith the headers produces matching pairs, thendict()builds a record. Result:{'id': 1, 'score': 95}and{'id': 2, 'score': 88}.
dict(zip(keys, values)) the most idiomatic way to build a dict from parallel lists. It handles any number of pairs, and raises ValueError if any key appears twice (which is usually what you want).🔄 Unzipping — Recovering Separate Sequences
The inverse of zip: given a list of tuples, zip(*rows) splits it back into separate sequences by transposing.
pairs = [(1, "a"), (2, "b"), (3, "c")] numbers, letters = zip(*pairs) print(numbers) print(letters) col1, col2 = zip((1, "x"), (2, "y")) print(col1, col2)
- 1A list of tuples — typically the shape of a zipped result.
- 3
*pairsunpacks the list into three separate arguments tozip:zip((1, 'a'), (2, 'b'), (3, 'c')). Each argument is a row, soziptransposes them into columns. - 4–5
numbersis(1, 2, 3),lettersis('a', 'b', 'c'). - 7–8The same trick with two tuples — result:
(1, 2)and('x', 'y').
zip(*pairs) unzip trick is useful when working with matrix-style data: it lets you quickly get "all the first columns" or "all the second columns" as separate tuples. It requires the input to be a list (not an iterator) — otherwise the star-unpacking consumes it before zip can iterate.🧩 Combining the Three
Real code rarely uses just one — here are the combinations that matter most
🔄 enumerate(zip(...)) — Indexed Parallel Iteration
When you need an index and multiple parallel values, wrap zip in enumerate.
names = ["Ana", "Bob", "Cara"] scores = [95, 88, 92] for i, (name, score) in enumerate(zip(names, scores), start=1): print(f"{i}. {name}: {score}")
- 1–2Two parallel lists.
- 4–5Inner
zipproduces(name, score)pairs. Outerenumeratewraps each pair with an index. The loop unpacks three values per iteration: the index, then the tuple split into name and score.
(name, score) are not optional — without them, the loop tries to unpack three values from each enumerate item and fails with ValueError. The tuple unpacking inside a tuple unpacking needs the parentheses to be clear.🎯 zip(*matrix) — Transposing a Grid
A grid is a list of rows; its transpose is a list of columns. zip(*matrix) gives you the columns directly.
grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
rows = grid
columns = list(zip(*grid))
print("Rows:", rows)
print("Columns:", columns)
for col in columns:
print(f"Column sum: {sum(col)}")- 1–5A 3×3 grid represented as a list of three rows.
- 7Rows are the input as-is.
- 8
zip(*grid)unpacks three rows into arguments tozip, producing three columns. Materialise withlist()to see all of them:[(1, 4, 7), (2, 5, 8), (3, 6, 9)]. - 10–11Print the rows and the transposed columns.
- 13–14Each column is a tuple, which supports
sum(). Results: 12, 15, 18.
.T instead — zip(*grid) only works when every row has the same length and you want a simple transpose.🗂️ enumerate(sorted(zip(...))) — Ranked Report
Combining zip, sorted, and enumerate gives you a ranked, numbered report in one expression.
names = ["Ana", "Bob", "Cara", "Dan"] scores = [95, 88, 92, 88] ranked = sorted(zip(scores, names), reverse=True) for rank, (score, name) in enumerate(ranked, start=1): print(f"{rank}. {name} — {score}")
- 1–2Four names and matching scores, including a tie (two 88s).
- 4
zip(scores, names)— note the order: score first, sosorted()sorts by score.reverse=Truesorts highest first. Ties are broken by name (alphabetical) because tuples compare lexicographically. - 6–7Enumerate the ranked pairs starting at 1, unpacking each pair into
scoreandname.
sorted(pairs, key=lambda p: p[1]) instead of relying on tuple order. It's clearer what the sort is based on — especially useful when the natural tuple order isn't what you want.⚠️ Edge Cases and Surprising Behaviours
What happens with empty inputs, mismatched lengths, and unusual arguments
📉 Empty Iterables
All three functions handle empty input gracefully — no exceptions, just zero iterations.
print(list(range(0))) print(list(enumerate([]))) print(list(zip([], []))) empty_sum = 0 for i in range(0): empty_sum += i print(empty_sum)
- 1
range(0)is empty — no numbers. Result:[]. - 2
enumerate([])yields nothing. Result:[]. - 3
zip([], [])produces nothing. Result:[]. - 5–7Loop over an empty range executes zero times —
empty_sumremains 0.
if items: guards before enumerate(items) or zip(a, b) — they already handle empty input correctly.🚨 range() with step=0
A step of zero would produce an infinite loop, so Python raises ValueError immediately when you construct the range.
try: r = range(0, 10, 0) except ValueError as e: print(f"ValueError: {e}")
- 2
range(0, 10, 0)— the third argument is the step. Zero step is illegal — the sequence would never advance and could never terminate. - 3–4Catches
ValueErrorand prints its message:"range() arg 3 must not be zero".
range is constructed, not when it's iterated. This is helpful — you catch the problem at the call site, not deep inside a loop.⚠️ zip() Truncation — The Silent Bug
The most common zip() bug is silently dropping data when iterables have different lengths. If you don't verify the lengths first, you might process fewer items than expected.
names = ["Ana", "Bob", "Cara"] scores = [95, 88] print(f"Names: {len(names)}, Scores: {len(scores)}") print(list(zip(names, scores))) if len(names) != len(scores): print("Warning: lists have different lengths") print(f"Missing score for: {names[-1]}")
- 1–2Three names, only two scores.
- 4Explicitly checks lengths before zipping — best practice when the two lists should be parallel.
- 5
zipsilently drops the extra name. Result:[('Ana', 95), ('Bob', 88)]. - 7–9Detects the mismatch and warns. In production code, this would typically raise
ValueErrorinstead of printing a warning.
if len(a) != len(b): raise ValueError(...) before any critical zip. Silent truncation is far more dangerous than a clear error.📉 consume once — Iterators Are Single-Use
All three functions return iterators, not lists. Once exhausted, they produce nothing more. Wrap in list() if you need to iterate multiple times.
pairs = zip([1, 2], ["a", "b"]) print(list(pairs)) print(list(pairs)) print(list(pairs)) pairs_list = list(zip([1, 2], ["a", "b"])) print(pairs_list) print(pairs_list)
- 1A
zipobject. Note: not a list. - 3First
list(pairs)consumes the iterator. Result:[(1, 'a'), (2, 'b')]. - 4Second
list(pairs)on the same exhausted iterator returns[]. - 5Third call also returns
[]. The iterator can only be consumed once. - 7Materialize eagerly into a list upfront.
- 8–9Both prints show the same data — lists can be iterated multiple times.
range too, but range is special: it's a sequence, so you can iterate it multiple times and index into it. enumerate and zip are true one-shot iterators.⚖️ At a Glance
When to reach for which
| Function | Purpose | Returns |
|---|---|---|
| range(start, stop, step) | Generate a numeric sequence | range object (a sequence) |
| enumerate(iterable, start=0) | Iterate with an index | enumerate object (yields pairs) |
| zip(*iterables) | Iterate several iterables in parallel | zip object (yields tuples) |
| itertools.zip_longest | Zip with padding for uneven lengths | zip_longest object |
Quick decision guide
Need numbers? → range().
Need an index too? → enumerate().
Need to iterate in parallel? → zip().
Parallel with padding? → itertools.zip_longest().
🏋️ Practice — Check Your Understanding
Three exercises with solutions. Try each before expanding the answer.
Exercise 1 — Predict the Output
What does each line print? Write your answers before expanding.
print(list(range(3, 10, 3))) print(list(enumerate("abc", start=1))) print(list(zip([1, 2, 3], "xy"))) print(list(range(5, 0)))
Show solution
Line 1: [3, 6, 9] — starts at 3, steps by 3, stops before 10.
Line 2: [(1, 'a'), (2, 'b'), (3, 'c')] — start=1 shifts the index.
Line 3: [(1, 'x'), (2, 'y')] — zip stops at the shortest, so the extra 3 is dropped.
Line 4: [] — positive step with start > stop produces nothing.
Exercise 2 — Fix the Bug
This loop skips the first item. Why? Fix it.
items = ["a", "b", "c"] for i in range(1, len(items)): print(items[i])
Show solution
range(1, len(items)) starts at index 1, skipping the first item entirely. Either use range(len(items)) to include index 0, or — better — use enumerate(items) which is the idiomatic form and automatically includes every item with its correct index:
for i, item in enumerate(items): print(item)
Exercise 3 — Build a Report
Given two parallel lists, build a ranked report: sort by score (highest first), number the entries starting from 1, and print each line as "rank. name — score".
names = ["Ana", "Bob", "Cara", "Dan"] scores = [95, 88, 92, 88]
Show solution
for rank, (score, name) in enumerate( sorted(zip(scores, names), reverse=True), start=1, ): print(f"{rank}. {name} — {score}")
Key points: (1) zip(scores, names) puts score first so sorted() sorts by score. (2) reverse=True gives highest first. (3) enumerate(..., start=1) gives user-facing ranking. (4) The inner parentheses around (score, name) are required to unpack the pair from each enumerate item.
❓ Frequently Asked Questions
Common questions about range, enumerate, and zip, answered directly
No. range(start, stop, step) is half-open: it includes start but excludes stop. range(0, 5) yields 0, 1, 2, 3, 4 — five values, not six. This matches Python's slicing semantics and is why range(1, 11) is the natural way to count from 1 to 10.
range() counts downward. range(10, 0, -2) yields 10, 8, 6, 4, 2. The stop value is excluded as always. A step of 0 raises ValueError — Python would not know how to advance, and the sequence would never terminate.
0 by default, matching Python's zero-based indexing. Use enumerate(iterable, start=1) if you want 1-based numbering for user-facing output. The start parameter can be any integer, including negatives — start=-1 is legal though rarely useful.
zip() stops as soon as the shortest iterable is exhausted, silently dropping remaining items from longer iterables. itertools.zip_longest() continues until the longest iterable is done, filling missing values with the fillvalue parameter (default None). Use zip_longest when you want to preserve all data; use zip when mismatched lengths should be treated as an error or a natural stopping point.
Yes — zip(*zipped) transposes the zipped pairs back into separate tuples. For example, zip([1, 2], [3, 4]) gives [(1, 3), (2, 4)]; zip(*[(1, 3), (2, 4)]) gives (1, 2) and (3, 4) as separate tuples. This works only if the zipped result is materialised (e.g. wrapped in list()) first — otherwise star-unpacking consumes the iterator before zip can use it.
All three return lazy objects in Python 3, not lists. range() returns a range object — technically a memory-efficient sequence (supports len(), indexing, and membership without expansion). enumerate() returns an enumerate object and zip() returns a zip object — both are true one-shot iterators. Convert with list() if you need to iterate multiple times or inspect the values.
📋 Quick-Reference Table
Every pattern at a glance — bookmark this page for quick reference
| Call | Result | Notes |
|---|---|---|
| range(5) | 0, 1, 2, 3, 4 | Stop is excluded; starts at 0 |
| range(2, 6) | 2, 3, 4, 5 | Start at 2, stop before 6 |
| range(0, 10, 2) | 0, 2, 4, 6, 8 | Step by 2 |
| range(10, 0, -1) | 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 | Negative step counts down |
| range(5, 5) | (empty) | Start equals stop |
| range(5, 2) | (empty) | Start > stop, positive step |
| range(0, 10, 0) | ValueError | Step cannot be zero |
| len(range(1, 10, 2)) | 5 | O(1) — no expansion |
| 5 in range(0, 10) | True | Membership test |
| sum(range(1, 11)) | 55 | Sum without a loop |
| enumerate(["a", "b"]) | (0, "a"), (1, "b") | Index starts at 0 |
| enumerate("abc", start=1) | (1, "a"), (2, "b"), (3, "c") | Custom starting index |
| enumerate({}, start=5) | (empty) | Empty iterable → no pairs |
| zip([1, 2], ["a", "b"]) | (1, "a"), (2, "b") | Parallel iteration |
| zip([1, 2, 3], ["a", "b"]) | (1, "a"), (2, "b") | Stops at shortest — extra 3 dropped |
| dict(zip(keys, values)) | {...} | Build a dict from two lists |
| zip(*rows) | columns | Transpose a grid |
| itertools.zip_longest(a, b) | Padded pairs | Stops at longest; fills with None |
| enumerate(zip(a, b), start=1) | (i, (a_i, b_i)) | Indexed parallel iteration |
Educational Use Disclaimer
This page is part of Poetic Bytes, a free educational resource for learning Python programming. The code examples are provided for instruction and demonstration. They are not production-audited and should not be used as-is in security-sensitive, financial, or safety-critical systems without independent review. See our full disclaimer and terms of use.
📬 Python, weekly
Short lessons, real code, and ecosystem news — sent about once a week
🗣️ Learner Voices
No comments yet — your voice starts the conversation.