Python 3.12+ · Updated November 2026 · Every program explained line by line

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}")
Written & reviewed by Glenn Junsay Pansensoy Last reviewed: November 2026 Python 3.12+ ~25 min read

🎯 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.

💡 All three return lazy iterators in Python 3 — they generate values on demand rather than building a list in memory. This makes them efficient for large data. Wrap in 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)))
Line-by-line explanation
  1. 1range(5) — single argument. Starts at 0, stops before 5, step 1. Result: [0, 1, 2, 3, 4].
  2. 2range(2, 8) — start at 2, stop before 8. Result: [2, 3, 4, 5, 6, 7].
  3. 3range(0, 20, 3) — step by 3. Result: [0, 3, 6, 9, 12, 15, 18].
  4. 4range(10, 0, -2) — negative step counts downward. Result: [10, 8, 6, 4, 2].
  5. 5range(5, 5) — start equals stop. Result: [] (empty). No values are produced.
  6. 6range(5, 2) — start greater than stop with positive step. Result: [] (empty). Python doesn't automatically count down — you must use a negative step.
Expected output[0, 1, 2, 3, 4] [2, 3, 4, 5, 6, 7] [0, 3, 6, 9, 12, 15, 18] [10, 8, 6, 4, 2] [] []
💡 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}")
Line-by-line explanation
  1. 1–2Loop over range(5) — i takes values 0, 1, 2, 3, 4. Five iterations, each printing a message.
  2. 4–5Loop over range(10, 0, -1) — counts down from 10 to 1. end=" " prints on the same line with a space separator.
  3. 6print() with no arguments prints a newline, ending the line of numbers.
  4. 8Initializes an accumulator.
  5. 9–10Loop over range(1, 11) — 1 through 10 inclusive. The upper bound 11 is excluded, which is why this sums 1 to 10.
  6. 11Prints the total: 55.
💡 For simply summing a range, use the built-in 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))
Line-by-line explanation
  1. 1range(1, 10, 2) — 1, 3, 5, 7, 9. Five values.
  2. 3Prints the range object representation: range(1, 10, 2). This is the whole object — not the expanded list.
  3. 4len(r) works in O(1) — the length is computed mathematically, not by counting. Result: 5.
  4. 5Indexing gives the first element: 1.
  5. 6Negative indexing gives the last element: 9.
  6. 7Membership test — 5 in r is True because 5 appears in the sequence.
  7. 84 in r is False — 4 is not in the sequence (odd numbers only).
  8. 9sum(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)))
Line-by-line explanation
  1. 1A three-item list.
  2. 3–4enumerate(fruits) yields (0, 'apple'), (1, 'banana'), (2, 'cherry'). The loop unpacks each pair into i and fruit.
  3. 6–7start=1 shifts the index to begin at 1 — useful for user-facing numbered lists.
  4. 9Materializes the enumerate object into a list of tuples: [(0, 'apple'), (1, 'banana'), (2, 'cherry')].
  5. 10start=100 begins 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}")
Line-by-line explanation
  1. 1A short string.
  2. 2A dict with two entries.
  3. 4–5Enumerating a string iterates character by character. 'hi' produces (0, 'h'), (1, 'i').
  4. 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.
  5. 10–11start=-1 starts the index at negative one. Unusual but legal — useful for countdown-style numbering.
💡 Enumerating a dict directly gives you the keys, not the key-value pairs. Use 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))
Line-by-line explanation
  1. 1A list of three task descriptions.
  2. 3Accumulator for the formatted lines.
  3. 4–6enumerate(tasks, start=1) numbers each task starting from 1. A conditional expression assigns a status based on position.
  4. 7Joins all lines with newlines for a single multi-line output.
Expected output1. Write docs (pending) 2. Review PR (pending) 3. Deploy (in progress)

🔗 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"])))
Line-by-line explanation
  1. 1–2Two equal-length lists.
  2. 4–5zip(names, scores) yields ('Ana', 95), ('Bob', 88), ('Cara', 92). The loop unpacks each pair.
  3. 7list(zip(...)) materializes the pairs: [('Ana', 95), ('Bob', 88), ('Cara', 92)].
  4. 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="-")))
Line-by-line explanation
  1. 1Imports zip_longest from the standard library's itertools module. Nothing to install.
  2. 3A four-item list.
  3. 4A two-item list.
  4. 6zip(a, b) stops when b runs out. Result: [(1, 'a'), (2, 'b')].
  5. 7zip_longest continues to four pairs, filling missing values with None. Result: [(1, 'a'), (2, 'b'), (3, None), (4, None)].
  6. 8fillvalue="-" overrides the default. Result: [(1, 'a'), (2, 'b'), (3, '-'), (4, '-')].
💡 Use 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)
Line-by-line explanation
  1. 1–2Two parallel lists: keys and their corresponding values.
  2. 4zip(keys, values) produces ('name', 'Ana'), ('age', 25), ('city', 'Manila'). dict() consumes those pairs to build the dict.
  3. 5Prints the resulting dict: {'name': 'Ana', 'age': 25, 'city': 'Manila'}.
  4. 7–8Column headers and rows — the shape you get from a CSV or database query.
  5. 10–12For each row, zip with the headers produces matching pairs, then dict() builds a record. Result: {'id': 1, 'score': 95} and {'id': 2, 'score': 88}.
💡 This is such a common pattern that many people consider 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)
Line-by-line explanation
  1. 1A list of tuples — typically the shape of a zipped result.
  2. 3*pairs unpacks the list into three separate arguments to zip: zip((1, 'a'), (2, 'b'), (3, 'c')). Each argument is a row, so zip transposes them into columns.
  3. 4–5numbers is (1, 2, 3), letters is ('a', 'b', 'c').
  4. 7–8The same trick with two tuples — result: (1, 2) and ('x', 'y').
💡 The 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}")
Line-by-line explanation
  1. 1–2Two parallel lists.
  2. 4–5Inner zip produces (name, score) pairs. Outer enumerate wraps each pair with an index. The loop unpacks three values per iteration: the index, then the tuple split into name and score.
Expected output1. Ana: 95 2. Bob: 88 3. Cara: 92
💡 The parentheses around (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)}")
Line-by-line explanation
  1. 1–5A 3×3 grid represented as a list of three rows.
  2. 7Rows are the input as-is.
  3. 8zip(*grid) unpacks three rows into arguments to zip, producing three columns. Materialise with list() to see all of them: [(1, 4, 7), (2, 5, 8), (3, 6, 9)].
  4. 10–11Print the rows and the transposed columns.
  5. 13–14Each column is a tuple, which supports sum(). Results: 12, 15, 18.
💡 For anything beyond 2D, use NumPy's .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}")
Line-by-line explanation
  1. 1–2Four names and matching scores, including a tie (two 88s).
  2. 4zip(scores, names) — note the order: score first, so sorted() sorts by score. reverse=True sorts highest first. Ties are broken by name (alphabetical) because tuples compare lexicographically.
  3. 6–7Enumerate the ranked pairs starting at 1, unpacking each pair into score and name.
Expected output1. Ana — 95 2. Cara — 92 3. Bob — 88 4. Dan — 88
💡 For complex sort keys, use 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)
Line-by-line explanation
  1. 1range(0) is empty — no numbers. Result: [].
  2. 2enumerate([]) yields nothing. Result: [].
  3. 3zip([], []) produces nothing. Result: [].
  4. 5–7Loop over an empty range executes zero times — empty_sum remains 0.
💡 Zero iterations is always safe. Don't add 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}")
Line-by-line explanation
  1. 2range(0, 10, 0) — the third argument is the step. Zero step is illegal — the sequence would never advance and could never terminate.
  2. 3–4Catches ValueError and prints its message: "range() arg 3 must not be zero".
💡 The error fires when the 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]}")
Line-by-line explanation
  1. 1–2Three names, only two scores.
  2. 4Explicitly checks lengths before zipping — best practice when the two lists should be parallel.
  3. 5zip silently drops the extra name. Result: [('Ana', 95), ('Bob', 88)].
  4. 7–9Detects the mismatch and warns. In production code, this would typically raise ValueError instead of printing a warning.
💡 A helpful pattern: 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)
Line-by-line explanation
  1. 1A zip object. Note: not a list.
  2. 3First list(pairs) consumes the iterator. Result: [(1, 'a'), (2, 'b')].
  3. 4Second list(pairs) on the same exhausted iterator returns [].
  4. 5Third call also returns []. The iterator can only be consumed once.
  5. 7Materialize eagerly into a list upfront.
  6. 8–9Both prints show the same data — lists can be iterated multiple times.
💡 This "iterator is single-use" rule applies to 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 sequencerange object (a sequence)
enumerate(iterable, start=0)Iterate with an indexenumerate object (yields pairs)
zip(*iterables)Iterate several iterables in parallelzip object (yields tuples)
itertools.zip_longestZip with padding for uneven lengthszip_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, 4Stop is excluded; starts at 0
range(2, 6)2, 3, 4, 5Start at 2, stop before 6
range(0, 10, 2)0, 2, 4, 6, 8Step by 2
range(10, 0, -1)10, 9, 8, 7, 6, 5, 4, 3, 2, 1Negative step counts down
range(5, 5)(empty)Start equals stop
range(5, 2)(empty)Start > stop, positive step
range(0, 10, 0)ValueErrorStep cannot be zero
len(range(1, 10, 2))5O(1) — no expansion
5 in range(0, 10)TrueMembership test
sum(range(1, 11))55Sum 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)columnsTranspose a grid
itertools.zip_longest(a, b)Padded pairsStops at longest; fills with None
enumerate(zip(a, b), start=1)(i, (a_i, b_i))Indexed parallel iteration
Review Python loops

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.

🗣️ Learner Voices

No comments yet — your voice starts the conversation.