For Loops: enumerate and Collection Operations
James is writing a function to print each SmartNotes tag with its position number. He sets up a counter variable, increments it inside the loop, and uses it in the f-string. It works, but the function is five lines long for something that feels like it should be simpler.
"You're doing it the hard way on purpose," Emma says, looking over his shoulder.
"There's an easier way?" James frowns at his counter variable.
Emma nods. "There is. But you needed to build it manually first, so you understand what the shortcut is actually doing."
When you loop through a list, sometimes you need both the item and its position (index 0, index 1, index 2, and so on). This lesson shows the manual way first, then introduces Python's built-in shortcut. Understanding the manual version helps you debug when the shortcut behaves unexpectedly.
Manual Index Tracking
You already know how to loop through a list from Lesson 2. But what if you need the position of each item? Here is how James does it with a manual counter:
tags: list[str] = ["python", "ai", "beginner"]
index: int = 0
for tag in tags:
print(f"{index}: {tag}")
index += 1
Output:
0: python
1: ai
2: beginner
It works. You initialize index at 0, use it inside the loop, and increment it by 1 at the end of each iteration. But there are two things to track: the item (tag) and the counter (index). The counter is boilerplate that adds nothing to the logic. Every time you write this pattern, you risk forgetting the index += 1 line and getting wrong numbers.
enumerate(): The Built-In Shortcut
Python's enumerate() function does the manual counting for you. It wraps a collection and produces pairs of (index, item) on each iteration:
tags: list[str] = ["python", "ai", "beginner"]
for i, tag in enumerate(tags):
print(f"{i}: {tag}")
Output:
0: python
1: ai
2: beginner
Same result, but no manual counter. The enumerate(tags) call produces tuples like (0, "python"), (1, "ai"), (2, "beginner"). The for i, tag in syntax unpacks each tuple into two variables on every iteration.
James stares at the two versions side by side. "So enumerate is just doing what I did manually?"
"Exactly," Emma says. "One less variable to manage, one less line to forget."
Tuple Unpacking in the Loop Header
The for i, tag in enumerate(tags) line uses tuple unpacking: Python takes each pair produced by enumerate() and assigns the first value to i and the second to tag. You can name these variables anything:
tags: list[str] = ["python", "ai", "beginner"]
for position, label in enumerate(tags):
print(f"Position {position} holds {label}")
Output:
Position 0 holds python
Position 1 holds ai
Position 2 holds beginner
The names position and label are just as valid as i and tag. Pick names that make the code readable. The convention is i for index when the index is a secondary detail, and a descriptive name when the index matters to the logic.
Mutating Lists: .remove()
You used .append() in Chapter 48 to add items to a list. Now you need the opposite: removing items. The .remove() method finds the first occurrence of a value and deletes it:
tags: list[str] = ["python", "ai", "beginner", "ai"]
tags.remove("ai")
print(tags)
Output:
['python', 'beginner', 'ai']
Notice that only the first "ai" was removed. The second "ai" at the end is still there. If the value does not exist in the list, .remove() raises a ValueError. Always make sure the item is present before calling .remove(), or be ready for the error.
Mutating Lists: .pop()
The .pop() method removes and returns the last item in the list:
tags: list[str] = ["python", "ai", "beginner"]
last_tag: str = tags.pop()
print(f"Removed: {last_tag}")
print(f"Remaining: {tags}")
Output:
Removed: beginner
Remaining: ['python', 'ai']
Unlike .remove(), which searches by value, .pop() always takes from the end. It also gives you the removed item back, which is useful when you need to process an item and remove it in one step. James thinks of it like pulling the last card off a stack.
Mutating Lists: .sort()
The .sort() method rearranges the list in place, from smallest to largest:
scores: list[int] = [85, 42, 97, 63]
scores.sort()
print(scores)
Output:
[42, 63, 85, 97]
James tries .sort() and then prints the list. He expects to see the sorted version returned, like a new list. Instead, .sort() returns None and changes the original list directly.
"Wait, the original list changed?" James asks.
"That is what 'in place' means," Emma explains. "The method modifies the list itself. It does not create a new one. If you print the return value of .sort(), you get None."
This is an important distinction. The .sort() method is different from the sorted() built-in function (which you may encounter later). For now, remember: .sort() changes the list and returns nothing.
For strings, .sort() arranges alphabetically:
tags: list[str] = ["python", "ai", "beginner"]
tags.sort()
print(tags)
Output:
['ai', 'beginner', 'python']
Building a Filtered List with a Loop
A common pattern combines a loop, an if branch (Lesson 1), and .append() (Chapter 48) to build a new list from an existing one:
tags: list[str] = ["python", "ai", "beginner", "data", "advanced"]
long_tags: list[str] = []
for i, tag in enumerate(tags):
if len(tag) > 4:
long_tags.append(tag)
print(f"Long tags: {long_tags}")
Output:
Long tags: ['python', 'beginner', 'advanced']
Here is the trace:
| Iteration | i | tag | len(tag) > 4 | Action |
|---|---|---|---|---|
| 0 | 0 | "python" | True (6 > 4) | Append |
| 1 | 1 | "ai" | False (2 > 4) | Skip |
| 2 | 2 | "beginner" | True (8 > 4) | Append |
| 3 | 3 | "data" | False (4 > 4) | Skip |
| 4 | 4 | "advanced" | True (8 > 4) | Append |
Start with an empty list, loop through the source, test each item, and .append() the ones that pass. This is the loop-and-filter pattern. You will use it constantly.
Testing Collection Operations
Wrap your logic in a function and test with assert:
def get_long_tags(tags: list[str], min_length: int) -> list[str]:
"""Return tags longer than min_length characters."""
result: list[str] = []
for tag in tags:
if len(tag) > min_length:
result.append(tag)
return result
assert get_long_tags(["python", "ai", "data"], 3) == ["python", "data"]
assert get_long_tags(["hi", "go"], 5) == []
assert get_long_tags([], 3) == []
print("All tests passed.")
Output:
All tests passed.
Three tests cover three cases: normal filtering, nothing passes the filter, and an empty input list. The empty list test confirms the loop handles the edge case correctly.
PRIMM-AI+ Practice: Enumerate and Mutation
Predict [AI-FREE]
Read each snippet and predict the output. Write your predictions and a confidence score from 1 to 5 before checking.
Snippet 1:
colors: list[str] = ["red", "green", "blue"]
for i, color in enumerate(colors):
print(f"{i}-{color}")
Snippet 2:
nums: list[int] = [10, 20, 30]
removed: int = nums.pop()
print(f"Got: {removed}, Left: {nums}")
Snippet 3:
items: list[str] = ["a", "b", "c", "b"]
items.remove("b")
print(items)
Snippet 4:
vals: list[int] = [3, 1, 4, 1, 5]
vals.sort()
print(vals)
Check your predictions
Snippet 1: Output is 0-red, 1-green, 2-blue (each on its own line). enumerate starts counting at 0 by default.
Snippet 2: Output is Got: 30, Left: [10, 20]. .pop() removes and returns the last item.
Snippet 3: Output is ['a', 'c', 'b']. .remove("b") deletes only the first "b". The second "b" at index 3 survives.
Snippet 4: Output is [1, 1, 3, 4, 5]. .sort() rearranges the list in place from smallest to largest.
If you got all four correct, you understand both enumerate and mutation methods. If you missed Snippet 3, the "first match only" behavior of .remove() is the common surprise.
Run
Create a file called enumerate_practice.py with the snippets above. Run uv run python enumerate_practice.py and compare to your predictions.
Investigate
For Snippet 3, add a second items.remove("b") call after the first one. Predict the result before running. Then try removing a value that does not exist in the list and observe the error message.
Modify
Change Snippet 1 to print the index starting from 1 instead of 0. You can do this by adding to i inside the f-string: f"{i + 1}-{color}". Predict the output, then run and verify.
Make [Mastery Gate]
Write a function remove_and_sort(items: list[str], to_remove: str) -> list[str] that:
- Creates a copy of the input list (use
items.copy()so you do not modify the original) - Removes the first occurrence of
to_removeif it exists (check withinfirst) - Sorts the remaining items alphabetically
- Returns the sorted list
Test it with:
assert remove_and_sort(["cherry", "apple", "banana"], "apple") == ["banana", "cherry"]
assert remove_and_sort(["x", "y", "z"], "w") == ["x", "y", "z"]
assert remove_and_sort([], "a") == []
Run uv run pytest on your file to confirm all tests pass.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Check Your Understanding
I just learned about enumerate() in Python. Here is my
understanding: "enumerate() gives you the index and the value
at the same time, so you don't need a manual counter variable."
Is my summary accurate? What details am I missing?
Read the response. Did it mention that enumerate() produces tuples? Did it explain that the index starts at 0 by default? Compare its answer to the manual tracking version from this lesson.
What you're learning: You are testing your mental model of enumerate() by stating it and letting the AI find gaps, the same Predict-then-verify pattern from PRIMM.
Prompt 2: Generate and Review
Write a typed Python function called find_indices that takes a
list[str] of items and a str target, and returns a list[int]
of all indices where target appears. Use enumerate and a for
loop. Include 3 assert tests covering: target found multiple
times, target not found, and empty list.
Review the output. Check: does it use enumerate() correctly? Does each test cover a distinct case? Are all variables type-annotated? If the function uses any technique you have not learned yet (like list comprehensions), ask the AI to rewrite it using only a for loop and .append().
What you're learning: You are reviewing AI-generated code against the specific patterns from this lesson: enumerate, loop-and-filter with .append(), and proper type annotations.
In SmartNotes, you might need to find which position a specific tag occupies, remove a tag by name, or sort tags alphabetically before displaying them. The enumerate() function gives you position tracking, .remove() handles deletion by value, and .sort() handles ordering. These operations combine with the loop-and-filter pattern to build most of the list processing a note-taking app needs.
Key Takeaways
-
enumerate()replaces manual index tracking. It produces (index, item) pairs, so you get the position and the value without maintaining a separate counter variable. -
Tuple unpacking in
for i, item in enumerate(collection)splits each pair. Python assigns the index to the first variable and the item to the second. Name them to match your intent. -
.remove()deletes the first match by value. If the value appears more than once, only the first occurrence is removed. If the value is missing, Python raises an error. -
.pop()removes and returns the last item. It is the only mutation method here that gives you back the removed element. Think of it as "take from the end." -
.sort()changes the list in place and returnsNone. The original list is modified directly. Do not writetags = tags.sort()because that assignsNonetotags.
Looking Ahead
You now know how to iterate with indices and mutate lists during processing. In Lesson 4, you will learn while loops and the break and continue keywords, which give you finer control over when a loop starts, stops, and skips iterations.