The for Loop and Sequence Iteration
The for Loop and Sequence Iteration
After reading this chapter, you will master sequence and collection iteration using the for loop, understand how the Python Iterator Protocol operates under the hood, generate numerical sequences using range(), iterate dictionaries across keys and items, and avoid modifying collections during iteration.
Mental model
Unlike C-style for loops (for (int i=0; i<n; i++)), Python’s for loop is an abstraction over the Iterator Protocol. It operates over any iterable (lists, tuples, dictionaries, sets, generators, strings, open file handles):
How CPython Executes "for item in collection:":
1. Calls iterator = iter(collection) ──▶ Calls collection.__iter__()
2. Enters internal loop:
try:
item = next(iterator) ──▶ Calls iterator.__next__()
[ Execute Loop Body ]
except StopIteration:
break ──▶ Terminates cleanly!
Because range(start, stop, step) generates numbers on demand without allocating an actual list in memory, range(1_000_000_000) consumes only 48 bytes of RAM!
Minimal example
Save as for_loop_mechanics.py:
# for_loop_mechanics.py
import sys
def main() -> None:
# 1. Iterating across a list of cluster nodes
nodes = ["k8s-node-01", "k8s-node-02", "k8s-node-03"]
print("--- Node Iteration ---")
for node in nodes:
print(f"Active node: {node}")
# 2. Dictionary iteration via .items()
node_metrics = {"k8s-node-01": 12.4, "k8s-node-02": 45.1, "k8s-node-03": 88.0}
print("\n--- Dictionary Key-Value Unpacking ---")
for hostname, load in node_metrics.items():
print(f"Host: {hostname:12} | CPU Load: {load:4.1f}%")
# 3. range() step and stride
print("\n--- Range Stride (Even ports from 8000 to 8006) ---")
for port in range(8000, 8008, 2):
print(f"Allocating port: {port}")
# Inspecting range memory efficiency
massive_range = range(1_000_000_000)
print(f"\nMemory size of range(1,000,000,000): {sys.getsizeof(massive_range)} bytes")
if __name__ == "__main__":
main()Run via uv run python for_loop_mechanics.py:
--- Node Iteration ---
Active node: k8s-node-01
Active node: k8s-node-02
Active node: k8s-node-03
--- Dictionary Key-Value Unpacking ---
Host: k8s-node-01 | CPU Load: 12.4%
Host: k8s-node-02 | CPU Load: 45.1%
Host: k8s-node-03 | CPU Load: 88.0%
--- Range Stride (Even ports from 8000 to 8006) ---
Allocating port: 8000
Allocating port: 8002
Allocating port: 8004
Allocating port: 8006
Memory size of range(1,000,000,000): 48 bytes
Worked examples
Case 1: Deconstructing the Iterator Protocol Manually
To understand what Python does behind the scenes during a for loop, you can execute the exact same protocol manually using iter() and next():
# manual_iterator_demo.py
def demonstrate_manual_iteration() -> None:
items = ["auth-svc", "billing-svc", "gateway-svc"]
# 1. Obtain iterator object
iterator = iter(items)
print("Iterator object created:", iterator)
# 2. Emulate the for-loop using while and StopIteration
print("\nExecuting manual loop:")
while True:
try:
item = next(iterator)
print(f" Pulled item from iterator: {item}")
except StopIteration:
print(" StopIteration raised! CPython caught it and terminated loop.")
break
if __name__ == "__main__":
demonstrate_manual_iteration()Run:
uv run python manual_iterator_demo.pyOutput:
Iterator object created: <list_iterator object at ...>
Executing manual loop:
Pulled item from iterator: auth-svc
Pulled item from iterator: billing-svc
Pulled item from iterator: gateway-svc
StopIteration raised! CPython caught it and terminated loop.
Case 2: File Iteration Line by Line (Constant Memory)
An open file object is an iterable that yields one line at a time on demand. Iterating directly over the file handle reads from disk incrementally without ever loading the full file into RAM:
# file_line_streamer.py
import tempfile
from pathlib import Path
def process_large_logfile(log_path: Path) -> int:
error_count = 0
# Opening the file returns an iterable stream
with log_path.open("r", encoding="utf-8") as stream:
for line in stream:
if "[ERROR]" in line:
error_count += 1
return error_count
if __name__ == "__main__":
with tempfile.TemporaryDirectory() as tmpdir:
dummy_log = Path(tmpdir) / "cluster.log"
dummy_log.write_text(
"2026-09-07 [INFO] Started\n"
"2026-09-07 [ERROR] Database timeout\n"
"2026-09-07 [INFO] Retrying\n"
"2026-09-07 [ERROR] Auth rejected\n"
)
total_errors = process_large_logfile(dummy_log)
print(f"Total error lines identified: {total_errors}")Run:
uv run python file_line_streamer.pyOutput:
Total error lines identified: 2
Pitfalls
Pitfall 1: Modifying a Collection While Iterating Over It
Mutating a list while iterating over it causes internal index shifts that skip items or loop indefinitely:
# THE BUG:
numbers = [1, 2, 3, 4, 5, 6]
for n in numbers:
if n % 2 == 0:
numbers.remove(n) # Skips elements during iteration!
print(numbers) # [1, 3, 5] by coincidence, but elements like 4 or 6 get skipped on other data!
# THE FIX: Iterate over a shallow copy or use a list comprehension
numbers = [n for n in numbers if n % 2 != 0]For dictionaries, attempting to add or remove keys during iteration immediately crashes with RuntimeError: dictionary changed size during iteration.
Pitfall 2: The range(len(sequence)) Anti-Pattern
Programmers coming from C or Java frequently write for i in range(len(items)): val = items[i]. This is unpythonic, verbose, and error-prone. Iterate directly over items:
# ANTI-PATTERN:
for i in range(len(servers)):
print(servers[i])
# IDIOMATIC:
for server in servers:
print(server)If you need the index, use enumerate(servers) (covered in chapter 6).
Exercises
- Write a
forloop that iterates over a dictionary and prints only the keys whose values exceed 100. - Generate all multiples of 5 between 50 and 100 in reverse order using
range(). - Demonstrate iterating over a string character by character to count how many vowels it contains.
- Implement a custom class with
__iter__and__next__that yields squares of numbers up to a maximum limit.
Further reading
- Python Language Reference: The
forstatement. - Python Data Model: The Iterator Protocol (
__iter__,__next__).