for Loops
Python's for loop iterates directly over the elements of any iterable — lists, strings, ranges, files, generators — rather than manually managing an index counter, which is the idiomatic way to loop in Python.
A for loop in Python walks through the items produced by an iterable, binding each one in turn to a loop variable and running the loop body once per item. Unlike C-style for (i = 0; i < n; i++) loops built around an index, Python's for is fundamentally about iteration over a sequence of values — indices are something you ask for explicitly (with range or enumerate) only when you actually need them.
The Iterable Concept
An iterable is any object capable of returning its elements one at a time — it implements __iter__ (or, for older-style iteration, __getitem__). Lists, tuples, strings, dictionaries, sets, ranges, files, and generators are all iterables. A for loop asks the iterable for an iterator (via iter()) and repeatedly calls next() on it until it's exhausted, which raises StopIteration — the for loop catches that internally and simply stops.
'Iterable' and 'iterator' are related but distinct concepts: an iterable can produce a fresh iterator each time (a list can be looped over repeatedly), while an iterator is itself the stateful object being advanced, and is generally exhausted after one full pass.
fruits = ['apple', 'banana', 'cherry']
for fruit in fruits:
print(fruit)
# apple
# banana
# cherryBasic Syntax
The syntax is for <variable> in <iterable>: followed by an indented block. The loop variable is a real name bound in the enclosing scope (function or module) — it is not scoped only to the loop body, and it retains its final value after the loop ends.
for letter in 'abc':
print(letter)
print(letter) # 'c' -- still accessible after the loop, holding the last valueIterating with range()
When you need a sequence of numbers rather than existing data, range(stop), range(start, stop), or range(start, stop, step) produces one lazily, without building a list in memory. range objects are themselves iterables (in fact, sequences supporting indexing and len()), and the stop value is always exclusive.
range() objects compute values on demand and take constant memory regardless of length — range(10_000_000) is created instantly and doesn't allocate ten million integers up front.
for i in range(5):
print(i) # 0 1 2 3 4
for i in range(2, 10, 2):
print(i) # 2 4 6 8
for i in range(5, 0, -1):
print(i) # 5 4 3 2 1The Loop Variable and What It Binds To
Each iteration rebinds the loop variable to the next item — it does not create a new variable per iteration. For mutable items, the loop variable is an alias to the actual object in the iterable, so mutating it in place affects the original data; reassigning the loop variable itself does not.
matrix = [[1, 2], [3, 4]]
for row in matrix:
row.append(0) # mutates the actual sublist in place
print(matrix) # [[1, 2, 0], [3, 4, 0]]
numbers = [1, 2, 3]
for n in numbers:
n = n * 10 # only rebinds the local name 'n', doesn't touch the list
print(numbers) # [1, 2, 3] -- unchangedNested for Loops
A for loop's body can contain another for loop, commonly used to walk 2D structures (grids, matrices, nested lists) or to produce combinations of two sequences. Each level of nesting adds one indentation level, and the inner loop runs to completion for every single iteration of the outer loop.
grid = [[1, 2, 3], [4, 5, 6]]
for row in grid:
for value in row:
print(value, end=' ')
print()
# 1 2 3 4 5 6
colors = ['red', 'green']
sizes = ['S', 'M']
for color in colors:
for size in sizes:
print(f'{color}-{size}')
# red-S red-M green-S green-MUnpacking in Loops
When each item produced by the iterable is itself a sequence (like a tuple), the loop variable can be a tuple pattern that unpacks it directly — this is exactly how dict.items(), enumerate(), and zip() are typically consumed.
Prefer enumerate() over manually tracking an index with range(len(seq)) — it's more readable, less error-prone, and works on any iterable, not just sequences supporting indexing.
pairs = [(1, 'a'), (2, 'b'), (3, 'c')]
for number, letter in pairs:
print(number, letter)
scores = {'alice': 90, 'bob': 85}
for name, score in scores.items():
print(f'{name}: {score}')
for index, fruit in enumerate(['apple', 'banana']):
print(index, fruit) # 0 apple / 1 banana
names = ['alice', 'bob']
ages = [30, 25]
for name, age in zip(names, ages):
print(f'{name} is {age}')Mutation During Iteration: A Serious Caveat
Modifying a list (or other mutable container) while iterating over it directly is a well-known source of bugs: removing or adding elements shifts the underlying positions the iterator is tracking, causing items to be skipped or the loop to behave inconsistently. Dictionaries and sets go further and raise a RuntimeError if their size changes during iteration.
numbers = [1, 2, 3, 4, 5]
for n in numbers:
if n % 2 == 0:
numbers.remove(n) # BUG: mutating list while iterating over it
print(numbers) # [1, 3, 5] happens to look right here, but is unreliable in general
d = {'a': 1, 'b': 2}
for key in d:
d['c'] = 3 # RuntimeError: dictionary changed size during iteration# Safe patterns: iterate over a copy, or build a new collection
numbers = [1, 2, 3, 4, 5]
for n in numbers[:]: # slicing makes a shallow copy to iterate over
if n % 2 == 0:
numbers.remove(n)
print(numbers) # [1, 3, 5] -- reliably correct
# Even better: build a new list instead of mutating in place
numbers = [1, 2, 3, 4, 5]
numbers = [n for n in numbers if n % 2 != 0]
print(numbers) # [1, 3, 5]Iterating Over Multiple Sequences with zip()
zip() combines two or more iterables into one iterator of tuples, pairing up corresponding elements. It stops as soon as the *shortest* input is exhausted — no error is raised for mismatched lengths, so verify lengths yourself if that matters, or use itertools.zip_longest (from the standard library) when you need to pad the shorter ones.
names = ['Ann', 'Ben', 'Cid']
scores = [95, 88]
for name, score in zip(names, scores):
print(name, score)
# Ann 95
# Ben 88
# 'Cid' is silently dropped -- zip stopped at the shorter listPractical Usage: Accumulation, Filtering, Transformation
Most for loops fall into a small number of common shapes: accumulating a running result, filtering items that meet a condition, or transforming each item into something new. For simple filter/transform cases, a list comprehension is often more idiomatic than a full loop — but a for loop remains the right tool whenever the body does more than build one resulting collection (side effects, multiple accumulators, early exits).
# Accumulation
total = 0
for price in [9.99, 19.99, 4.50]:
total += price
print(round(total, 2)) # 34.48
# Filtering + transformation together (comprehension is more idiomatic here)
words = ['apple', 'kiwi', 'fig', 'banana']
long_upper = [w.upper() for w in words if len(w) > 3]
print(long_upper) # ['APPLE', 'BANANA']Key Takeaways
for loops iterate directly over an iterable's elements — Python idiom avoids manual index bookkeeping wherever possible.
range() produces numbers lazily and takes constant memory; the stop value is always exclusive.
The loop variable is a real name that persists after the loop and, for mutable items, aliases the actual object — mutating it affects the source, but reassigning it does not.
Unpack tuple-like items directly in the for clause (for k, v in d.items():), and prefer enumerate()/zip() over manual indexing.
Never mutate a list, dict, or set while iterating over it directly — iterate over a copy (for x in items[:]) or build a new collection instead.
zip() stops at the shortest input silently; use itertools.zip_longest when mismatched lengths need to be handled explicitly.
for n in numbers: if n % 2 == 0: numbers.remove(n) for n in numbers[:]: if n % 2 == 0: numbers.remove(n) # or, cleaner: numbers = [n for n in numbers if n % 2 != 0]
Removing items from a list while iterating over it directly shifts subsequent indices, silently skipping elements. Iterate over a copy, or build a new filtered list instead.
for i in range(len(items)): print(items[i]) for item in items: print(item)
Iterating by manual index is un-Pythonic and error-prone when you don't actually need the index. Iterate over the items directly; use enumerate(items) only when the index itself is needed.
for n in numbers: n = n * 2 # expecting this to modify 'numbers' numbers = [n * 2 for n in numbers]
Reassigning the loop variable only rebinds the local name to a new object; it never writes back into the original list. Build a new collection (e.g. with a comprehension) to produce transformed values.
for key in some_dict: some_dict[new_key] = value for key in list(some_dict.keys()): some_dict[new_key] = value # or accumulate updates separately and apply them after the loop
Changing a dictionary's size while iterating over it raises RuntimeError: dictionary changed size during iteration. Iterate over a snapshot of the keys, or defer the mutation until after the loop.
What protocol does a for loop actually use under the hood to walk through an iterable?
It calls iter() on the iterable to get an iterator, then repeatedly calls next() on that iterator until StopIteration is raised, at which point the loop ends automatically.
Why is range(10_000_000) created instantly and cheap in memory, unlike building a list of ten million integers?
range objects are lazy sequences: they store only start, stop, and step, and compute each value on demand as the loop requests it, rather than pre-allocating every value up front.
Explain the difference in effect between row.append(0) and row = row + [0] inside a for row in matrix: loop.
row.append(0) mutates the actual list object in place, which is visible through every other reference to that same sublist (including inside matrix). row = row + [0] creates a brand-new list and rebinds only the local name row to it, leaving the original sublist inside matrix unchanged.
Why does modifying a list while iterating over it directly cause elements to be skipped?
The iterator tracks a numeric position internally; removing an element shifts every later element one index to the left, so the position the iterator advances to next actually skips over an element that moved into the just-vacated slot.
What happens when you zip() two lists of different lengths, and how would you handle the mismatch explicitly?
zip() stops as soon as the shorter iterable is exhausted, silently dropping the extra elements from the longer one with no error. Use itertools.zip_longest(a, b, fillvalue=...) from the standard library if you need to pad and preserve every element.
Why does the loop variable in for x in items: still hold a value after the loop finishes, and is that safe to rely on?
Python's for loop does not create a new scope — the loop variable is bound in the enclosing function/module scope, so it retains its last-assigned value after the loop ends. It's technically accessible, but relying on it is fragile: if the iterable was empty, the variable retains whatever value it had before the loop (or raises NameError if never previously assigned), so it's best avoided as a design pattern.
What does this print?
10. range(1, 5) yields 1, 2, 3, 4 (stop is exclusive), and 1+2+3+4 = 10.
Write a function flatten(matrix) that takes a list of lists and returns a single flat list of all the values, using nested for loops.
def flatten(matrix: list[list[int]]) -> list[int]: result = [] for row in matrix: for value in row: result.append(value) return result print(flatten([[1, 2], [3, 4], [5]])) # [1, 2, 3, 4, 5]
The outer loop walks each sublist ('row'); the inner loop walks each value within that row, appending every value to a single accumulator list in order.
What does this print, and what does the start=1 argument do?
Prints '1 a', '2 b', '3 c'. enumerate() normally starts counting at 0; start=1 shifts the beginning index to 1, useful for human-facing 1-based numbering without manual offset arithmetic.
Write a function pair_names_scores(names, scores) that returns a dictionary mapping each name to its corresponding score, using zip() inside a for loop (not dict(zip(...))).
def pair_names_scores(names: list[str], scores: list[int]) -> dict[str, int]: result = {} for name, score in zip(names, scores): result[name] = score return result print(pair_names_scores(['Ann', 'Ben'], [95, 88])) # {'Ann': 95, 'Ben': 88}
zip(names, scores) pairs elements positionally into tuples; unpacking each tuple directly in the for clause lets us build the dictionary one key-value pair per iteration, stopping automatically at the shorter of the two input lists.
total = 0
for i in range(1, 5):
total += i
print(total)names = ['a', 'b', 'c']
for i, name in enumerate(names, start=1):
print(i, name)