The sprint-summary script. The leaderboard loop. You have i = 0 before the loop, score = scores[i], rank = i + 1, and i += 1 at the bottom. Four lines of index management for one operation.
I needed both the index and the value. I couldn't do that with a plain for name in names loop, so I maintained the counter manually. I know enumerate exists — I use it for the zero-indexed case.
enumerate takes a start argument:
# Before: manual index counter
i = 0
for name in names:
score = scores[i]
rank = i + 1
print(f"#{rank}: {name} — {score}")
i += 1
# After: zip pairs the lists, enumerate provides the rank
for rank, (name, score) in enumerate(zip(names, scores), start=1):
print(f"#{rank}: {name} — {score}")zip(names, scores) pairs them into tuples. enumerate(..., start=1) adds the rank counter starting at 1. The nested unpacking rank, (name, score) pulls all three values in one line. No manual counter. No parallel indexing.
The zip stops when the shorter list runs out. If my two lists have different lengths, I silently get the wrong number of rows — no error, just truncation. That could hide a data problem.
Python 3.10 added zip(..., strict=True). It raises ValueError if the iterables have different lengths. Use it whenever mismatched lengths represent a data error rather than intentional truncation. You have a pipeline that zips API results from two endpoints — add strict=True there. Silent truncation is the most common zip bug in production.
I did not know strict=True existed. One of those API calls occasionally returns fewer items and we've been producing silently wrong output. I need to add that today.
When you need to iterate in reverse, most people write range(len(items) - 1, -1, -1). That is the range-len pattern with off-by-one risk added. Python has reversed():
# Non-Pythonic — index arithmetic
for i in range(len(items) - 1, -1, -1):
process(items[i])
# Pythonic — no index, no arithmetic
for item in reversed(items):
process(item)reversed() returns an iterator, not a copy. No extra memory. And it combines with enumerate if you need the position within the reversed traversal.
I have a "most recent first" display function that uses that exact range expression. I remember being proud of figuring out the off-by-one. reversed() existed the entire time.
That range expression is a rite of passage. You write it once. reversed() makes sure you never write it again. When you need the maximum across paired data — tracking both a name and its score — use max with a key argument instead of a two-variable update loop:
# Non-Pythonic — sentinel value + conditional update
best_name, best_score = None, -1
for name, score in zip(names, scores):
if score > best_score:
best_score = score
best_name = name
# Pythonic — max returns the whole pair
best_name, best_score = max(zip(names, scores), key=lambda pair: pair[1])max iterates the pairs, applies the key, and returns the winning pair — not just the key value.
I have that sentinel pattern in three places. Comments explaining the update logic. If max with a key returns the full pair, all three become one-liners and the comments disappear.
The comment disappears not because you removed it — because the code no longer needs explanation. The Week 1 toolkit: enumerate with start for ranked output, zip for parallel sequences, zip(..., strict=True) when mismatched lengths are a data error, reversed() for backward traversal, and max/min with key for value-carrying comparisons. Every place you were managing iteration state manually is a place Python already had a built-in for.
enumerate, zip, and reversed Work Under the Hoodenumerate is a lazy wrapper. enumerate(iterable, start=0) returns an enumerate object that wraps the original iterable. Each call to __next__() increments an internal counter (starting at start) and calls next() on the wrapped iterable, then yields a (count, element) tuple. No new list is created. Memory usage is O(1) regardless of the iterable's length. The start parameter shifts the counter's origin — it does not change the number of iterations or the elements consumed.
zip and lazy evaluation. zip(*iterables) returns a zip object. Each call to __next__() calls next() on each wrapped iterable in order and yields a tuple. If any iterable is exhausted first, the zip object stops immediately. This is strict truncation at the shortest — no padding, no error. zip(strict=True) adds a second check after yielding: after the first iterable signals exhaustion, it calls next() on all remaining iterables to verify they are also exhausted. If any still has elements, it raises ValueError. The strict check happens after the last valid tuple is yielded, which means the first N tuples are always produced before the length mismatch is detected.
Why range(len(x)) is an anti-pattern. range(len(x)) forces you to go through an index to reach an element. The index is not your goal — the element is. Every items[i] inside the loop is a subscript operation that exists only to undo the indirection you introduced by iterating indices in the first place. The pattern also breaks for iterables that do not support subscript access (generators, file objects). for item in iterable works on any iterable; for i in range(len(iterable)) requires the iterable to be a sequence with a known length and __getitem__.
reversed() and the __reversed__ protocol. reversed(seq) first checks if the object defines __reversed__. Lists and ranges define it and return efficient iterators that step backward without creating a copy. If __reversed__ is not defined but __len__ and __getitem__ are, Python constructs a fallback reversed iterator using those. Custom classes can opt into reversed() by defining __reversed__. The common mistake is calling list(reversed(items)) when you only need a single pass — that creates a full copy just to iterate it once forward. Call reversed(items) directly in the loop.
Sign up to write and run code in this lesson.
The sprint-summary script. The leaderboard loop. You have i = 0 before the loop, score = scores[i], rank = i + 1, and i += 1 at the bottom. Four lines of index management for one operation.
I needed both the index and the value. I couldn't do that with a plain for name in names loop, so I maintained the counter manually. I know enumerate exists — I use it for the zero-indexed case.
enumerate takes a start argument:
# Before: manual index counter
i = 0
for name in names:
score = scores[i]
rank = i + 1
print(f"#{rank}: {name} — {score}")
i += 1
# After: zip pairs the lists, enumerate provides the rank
for rank, (name, score) in enumerate(zip(names, scores), start=1):
print(f"#{rank}: {name} — {score}")zip(names, scores) pairs them into tuples. enumerate(..., start=1) adds the rank counter starting at 1. The nested unpacking rank, (name, score) pulls all three values in one line. No manual counter. No parallel indexing.
The zip stops when the shorter list runs out. If my two lists have different lengths, I silently get the wrong number of rows — no error, just truncation. That could hide a data problem.
Python 3.10 added zip(..., strict=True). It raises ValueError if the iterables have different lengths. Use it whenever mismatched lengths represent a data error rather than intentional truncation. You have a pipeline that zips API results from two endpoints — add strict=True there. Silent truncation is the most common zip bug in production.
I did not know strict=True existed. One of those API calls occasionally returns fewer items and we've been producing silently wrong output. I need to add that today.
When you need to iterate in reverse, most people write range(len(items) - 1, -1, -1). That is the range-len pattern with off-by-one risk added. Python has reversed():
# Non-Pythonic — index arithmetic
for i in range(len(items) - 1, -1, -1):
process(items[i])
# Pythonic — no index, no arithmetic
for item in reversed(items):
process(item)reversed() returns an iterator, not a copy. No extra memory. And it combines with enumerate if you need the position within the reversed traversal.
I have a "most recent first" display function that uses that exact range expression. I remember being proud of figuring out the off-by-one. reversed() existed the entire time.
That range expression is a rite of passage. You write it once. reversed() makes sure you never write it again. When you need the maximum across paired data — tracking both a name and its score — use max with a key argument instead of a two-variable update loop:
# Non-Pythonic — sentinel value + conditional update
best_name, best_score = None, -1
for name, score in zip(names, scores):
if score > best_score:
best_score = score
best_name = name
# Pythonic — max returns the whole pair
best_name, best_score = max(zip(names, scores), key=lambda pair: pair[1])max iterates the pairs, applies the key, and returns the winning pair — not just the key value.
I have that sentinel pattern in three places. Comments explaining the update logic. If max with a key returns the full pair, all three become one-liners and the comments disappear.
The comment disappears not because you removed it — because the code no longer needs explanation. The Week 1 toolkit: enumerate with start for ranked output, zip for parallel sequences, zip(..., strict=True) when mismatched lengths are a data error, reversed() for backward traversal, and max/min with key for value-carrying comparisons. Every place you were managing iteration state manually is a place Python already had a built-in for.
enumerate, zip, and reversed Work Under the Hoodenumerate is a lazy wrapper. enumerate(iterable, start=0) returns an enumerate object that wraps the original iterable. Each call to __next__() increments an internal counter (starting at start) and calls next() on the wrapped iterable, then yields a (count, element) tuple. No new list is created. Memory usage is O(1) regardless of the iterable's length. The start parameter shifts the counter's origin — it does not change the number of iterations or the elements consumed.
zip and lazy evaluation. zip(*iterables) returns a zip object. Each call to __next__() calls next() on each wrapped iterable in order and yields a tuple. If any iterable is exhausted first, the zip object stops immediately. This is strict truncation at the shortest — no padding, no error. zip(strict=True) adds a second check after yielding: after the first iterable signals exhaustion, it calls next() on all remaining iterables to verify they are also exhausted. If any still has elements, it raises ValueError. The strict check happens after the last valid tuple is yielded, which means the first N tuples are always produced before the length mismatch is detected.
Why range(len(x)) is an anti-pattern. range(len(x)) forces you to go through an index to reach an element. The index is not your goal — the element is. Every items[i] inside the loop is a subscript operation that exists only to undo the indirection you introduced by iterating indices in the first place. The pattern also breaks for iterables that do not support subscript access (generators, file objects). for item in iterable works on any iterable; for i in range(len(iterable)) requires the iterable to be a sequence with a known length and __getitem__.
reversed() and the __reversed__ protocol. reversed(seq) first checks if the object defines __reversed__. Lists and ranges define it and return efficient iterators that step backward without creating a copy. If __reversed__ is not defined but __len__ and __getitem__ are, Python constructs a fallback reversed iterator using those. Custom classes can opt into reversed() by defining __reversed__. The common mistake is calling list(reversed(items)) when you only need a single pass — that creates a full copy just to iterate it once forward. Call reversed(items) directly in the loop.