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.
Iterating Over Dictionaries
You learned dict[str, int] in Chapter 48. A for loop on a dict iterates over its keys by default:
word_counts: dict[str, int] = {"python": 3, "testing": 1, "ai": 2}
for tag in word_counts:
print(tag)
Output:
python
testing
ai
To get both the key and the value, use .items():
for tag, count in word_counts.items():
print(f"{tag}: {count}")
Output:
python: 3
testing: 1
ai: 2
The .items() method returns (key, value) tuples, and the loop unpacks them (just like enumerate() unpacks (index, item) tuples). Two other methods: .keys() returns only the keys (same as iterating the dict directly), and .values() returns only the values.
A common pattern is counting with a dict: start with an empty dict and add to it as you loop:
tags: list[str] = ["python", "ai", "python", "web", "ai", "python"]
counts: dict[str, int] = {}
for tag in tags:
counts[tag] = counts.get(tag, 0) + 1
print(counts)
Output:
{'python': 3, 'ai': 2, 'web': 1}
The .get(tag, 0) returns the current count for tag, or 0 if tag has not been seen yet. You will use this counting pattern in Lesson 5 and in the SmartNotes TDG capstone.
PRIMM-AI+ Practice: Enumerate and Mutation
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
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
Press Shift+Tab to exit Plan Mode.
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.
If you want to go deeper, run /investigate @enumerate_practice.py in Claude Code and ask why .remove() only deletes the first match.
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.
Prompt 3: Apply enumerate or Dict Iteration to Your Own Data
I have a collection of items from my work. For example, a list of
product names, a dict mapping employee IDs to departments, or a
list of transaction descriptions. Write a Python function that
uses enumerate() or .items() to produce a numbered or labeled
report. Use type annotations and include 2 assert tests.
Replace the example with real data from your own domain. Review the AI's output: does it use enumerate() or .items() correctly? Does the loop-and-filter pattern match what you learned in this lesson? If the AI uses a technique you have not learned yet (like list comprehensions), ask it to rewrite using only a for loop.
What you're learning: You are connecting enumerate() and dict iteration to data you actually work with, which makes the pattern stick.
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.
"So enumerate() is the shortcut for what I was doing with a manual counter," James says. "One less variable, one less line to forget. And the three mutation methods: .remove() finds the first match and deletes it, .pop() pulls the last item off the stack and gives it back, and .sort() reorders in place." He pauses. "That .sort() returning None tripped me up. I wrote tags = tags.sort() and wiped out my entire list."
"That one gets everyone," Emma says. "I still double-check myself on it. The mental model is: .sort() changes the list itself, so there is nothing to return. If you assign the result, you get None."
James nods. "It's like the difference between reorganizing a warehouse shelf versus building a new shelf. .sort() reorganizes in place. You don't get a new shelf back."
Emma tilts her head. "That's actually a useful way to think about it. I might steal that."
"So I can iterate with indices, add, remove, and reorder. But all of these loops run start to finish. What if I need to stop the moment I find what I'm looking for?"
"That's Lesson 4. while loops run until a condition flips. And break lets you exit early."