Skip to main content

Nested Loops and Patterns

James has a problem. Each note in SmartNotes has its own list of tags. He wants to collect every unique tag across all notes into a single set. He writes a for loop that iterates over the list of notes, but each item is itself a list of tags. One loop is not enough.

"You need a loop inside a loop," Emma says. She pulls up the whiteboard and draws two boxes, one inside the other. "The outer loop picks a note's tag list. The inner loop walks through that tag list, one tag at a time. The inner loop runs completely for every single step of the outer loop."

James traces it by hand with a small example: three notes, each with a few tags. The outer loop takes the first note's tags. The inner loop visits each tag. Then the outer loop moves to the second note, and the inner loop starts over from the beginning of that note's tags.

"So 3 outer times 4 inner means 12 total iterations?" he asks.

"Exactly. Now you understand why nested loops can be slow."

If you're new to programming

A nested loop is a loop inside another loop. The inner loop runs all the way through for each single step of the outer loop. If the outer loop runs 3 times and the inner loop runs 4 times each, the code inside the inner loop executes 3 x 4 = 12 times total. This multiplication effect is the key thing to remember.

If you know loops from another language

Python nested loops work the same as in most languages. The main surprise for newcomers: break exits only the innermost loop. Python has no labeled break (like Java's break outer;). If you need to exit both loops, use a flag variable or extract the inner loop into a function that returns early.


Nested for Loops

Here is the pattern James needs. Each note has a list of tags. He wants every unique tag across all notes:

all_tags: list[list[str]] = [["python", "ai"], ["testing"], ["python", "web"]]
unique_tags: set[str] = set()

for note_tags in all_tags:
for tag in note_tags:
unique_tags.add(tag)

print(unique_tags)

Output:

{'python', 'ai', 'testing', 'web'}

The outer loop picks each inner list (["python", "ai"], then ["testing"], then ["python", "web"]). The inner loop visits every string inside that inner list. Because unique_tags is a set, the duplicate "python" is added only once.


Trace Table: Seeing Every Step

When nested loops confuse you, a trace table makes the execution visible. Write down each variable's value at every step of the inner loop. Here is a 3x3 example:

colors: list[str] = ["red", "blue", "green"]
sizes: list[str] = ["S", "M", "L"]
combos: list[str] = []

for color in colors:
for size in sizes:
combo: str = f"{color}-{size}"
combos.append(combo)

Trace table:

Outer stepcolorInner stepsizecombo
1"red"1"S""red-S"
1"red"2"M""red-M"
1"red"3"L""red-L"
2"blue"1"S""blue-S"
2"blue"2"M""blue-M"
2"blue"3"L""blue-L"
3"green"1"S""green-S"
3"green"2"M""green-M"
3"green"3"L""green-L"

3 colors x 3 sizes = 9 total iterations. The inner loop resets to "S" every time the outer loop advances to the next color. Drawing this table is the single best debugging technique for nested loops.


Nested if Inside Loops

You can filter while you iterate by putting an if inside a loop. This pattern collects only the tags that start with "py":

all_tags: list[list[str]] = [["python", "ai"], ["testing"], ["python", "web"]]
py_tags: list[str] = []

for note_tags in all_tags:
for tag in note_tags:
if tag.startswith("py"):
py_tags.append(tag)

print(py_tags)

Output:

['python', 'python']

The if runs inside the inner loop, so it checks every individual tag. Only the tags passing the condition get appended.


break Exits the Inner Loop Only

This is the most common source of confusion with nested loops. When break appears inside an inner loop, it exits only that inner loop. The outer loop keeps running:

all_tags: list[list[str]] = [["python", "ai"], ["testing"], ["python", "web"]]

for note_tags in all_tags:
for tag in note_tags:
if tag == "python":
print(f"Found python in {note_tags}")
break # exits inner loop; outer loop continues

Output:

Found python in ['python', 'ai']
Found python in ['python', 'web']

The break stops the inner loop as soon as "python" is found in a given note's tags. But the outer loop is unaffected. It moves on to the next note's tag list and the inner loop starts fresh. The middle note (["testing"]) has no "python", so nothing prints for it.

If you wanted to stop both loops entirely after the first "python", you would need a flag variable:

found: bool = False
for note_tags in all_tags:
for tag in note_tags:
if tag == "python":
found = True
break
if found:
break

Flattening Nested Data

A common task: you have a list of lists and you want a single flat list. Use a nested for loop with .append():

all_tags: list[list[str]] = [["python", "ai"], ["testing"], ["python", "web"]]
flat_tags: list[str] = []

for note_tags in all_tags:
for tag in note_tags:
flat_tags.append(tag)

print(flat_tags)

Output:

['python', 'ai', 'testing', 'python', 'web']

Every tag from every inner list ends up in one flat list. Notice that "python" appears twice because flattening preserves duplicates. If you want unique values, use a set as shown in the first example.


The "Modify While Iterating" Bug

This bug catches almost every beginner. You have a list of tags and want to remove all tags equal to "draft". The obvious approach looks correct but is dangerous:

# WRONG: modifying a list while iterating over it
tags: list[str] = ["python", "ai", "draft", "testing"]
for tag in tags:
if tag == "draft":
tags.remove(tag) # dangerous! skips elements

print(tags)

Output (unpredictable):

['python', 'ai', 'testing']

This might look correct for this specific input, but the behavior is unreliable. When you remove an element, every element after it shifts left. The loop's internal counter advances past the shifted element, silently skipping it. With two consecutive items to remove, the bug becomes visible.

The correct approach: build a new list containing only the items you want to keep.

# CORRECT: build a new list
tags: list[str] = ["python", "ai", "draft", "testing"]
clean_tags: list[str] = []

for tag in tags:
if tag != "draft":
clean_tags.append(tag)

print(clean_tags)

Output:

['python', 'ai', 'testing']

The original list is never modified during iteration. The new list accumulates only the tags that pass the filter. This pattern is safe, predictable, and easy to test.

Three levels of nesting is a refactor signal

If you find yourself writing a loop inside a loop inside a loop, take a step back. Three levels of nesting usually means the inner logic should be extracted into its own function. One function handles the outer loop; another function handles the inner work. This keeps each piece readable and testable.


PRIMM-AI+ Practice: Nested Loop Predictions

Predict [AI-FREE]

Look at this code without running it. Write your predictions and a confidence score from 1 to 5 before checking.

Prediction 1: What is the value of result after this code runs?

grid: list[list[int]] = [[1, 2], [3, 4], [5, 6]]
result: int = 0

for row in grid:
for num in row:
result += num

Prediction 2: How many times does print execute?

outer: list[str] = ["a", "b"]
inner: list[str] = ["x", "y", "z"]

for o in outer:
for i in inner:
print(f"{o}{i}")

Prediction 3: What does this code print?

data: list[list[str]] = [["stop", "go"], ["run", "stop"], ["fly"]]

for group in data:
for word in group:
if word == "stop":
break
print(word)
Check your predictions

Prediction 1: result is 21. The inner loop visits 1, 2, 3, 4, 5, 6 and adds them all. 1+2+3+4+5+6 = 21.

Prediction 2: print executes 6 times. 2 outer x 3 inner = 6. Output: ax, ay, az, bx, by, bz.

Prediction 3: The output is:

run
fly

First group ["stop", "go"]: the inner loop hits "stop" immediately and break exits the inner loop. Nothing prints for this group. Second group ["run", "stop"]: "run" prints, then "stop" triggers break. Third group ["fly"]: "fly" is not "stop", so it prints. The break exits only the inner loop each time; the outer loop always continues.

Run

Create a file called nested_practice.py with the Prediction 3 code. Run uv run python nested_practice.py. Compare the output to your prediction. If it differs, trace through the table row by row to find where your reasoning diverged.

Investigate

Modify the Prediction 3 code: change the condition from word == "stop" to word == "run". Before running, predict the new output. Then run and verify. Trace through the table to explain why each group produced its output.

Modify

Add a fourth group ["jump", "stop", "land"] to the data list. Predict how many words print total across all four groups. Run and verify.

Make [Mastery Gate]

Without looking at any examples, write a function called find_shared_tags(all_tags: list[list[str]]) -> list[str] that returns a list of tags appearing in more than one inner list. For example:

all_tags: list[list[str]] = [["python", "ai"], ["testing"], ["python", "web"]]
# "python" appears in list 0 and list 2 → return ["python"]

Write at least three test functions:

  • One where a tag appears in two lists
  • One where no tags are shared
  • One where multiple tags are shared

Run uv run pytest to verify all tests pass.

Hint

For each tag, count how many inner lists contain it. One approach: loop through each inner list, convert it to a set, and track how many lists each tag appears in using a dictionary as a counter.


Exercises

Exercise 1: Spot the Bug

This function is supposed to stop searching all notes as soon as it finds any note containing the target tag. But the break is in the wrong place:

def has_tag_anywhere(
all_tags: list[list[str]], target: str
) -> bool:
"""Return True if target appears in any inner list."""
for note_tags in all_tags:
for tag in note_tags:
if tag == target:
return True
break # BUG: where should this be?
return False

What does has_tag_anywhere([["ai", "python"], ["web"]], "python") return? Why? Fix the function so it works correctly.

Hint

The break is at the same indentation level as the if, which means it runs on every iteration where the tag does NOT match. This exits the inner loop after checking only the first tag in each list.

Solution

The bug: break is not inside the if block, so it runs unconditionally after the first tag of every inner list. The function only ever checks the first tag of each list. For ["ai", "python"], it checks "ai", does not match, and break exits the inner loop. "python" is never checked.

The fix: remove the break entirely. The return True already exits the function when the tag is found. The loops naturally continue until all tags are checked.

def has_tag_anywhere(
all_tags: list[list[str]], target: str
) -> bool:
"""Return True if target appears in any inner list."""
for note_tags in all_tags:
for tag in note_tags:
if tag == target:
return True
return False

Exercise 2: Write the Test

Write a function find_duplicate_tags(all_tags: list[list[str]]) -> list[str] that returns a sorted list of tags appearing more than once across all inner lists (counting total occurrences, not unique lists). For example, if "python" appears three times total across all lists, it should be in the result.

Then write test functions covering:

  • A case with duplicates
  • A case with no duplicates
  • A case where every tag is duplicated
Starter code
def find_duplicate_tags(all_tags: list[list[str]]) -> list[str]:
"""Return sorted list of tags that appear more than once total."""
# Step 1: Count occurrences of each tag
counts: dict[str, int] = {}
for note_tags in all_tags:
for tag in note_tags:
# Your counting logic here
...

# Step 2: Collect tags with count > 1
duplicates: list[str] = []
# Your filtering logic here
...

duplicates.sort()
return duplicates


def test_with_duplicates() -> None:
data: list[list[str]] = [["python", "ai"], ["python", "web"]]
assert find_duplicate_tags(data) == ["python"]

# Write test_no_duplicates and test_all_duplicated

Exercise 3: Build It

This is the first "build it" exercise in the course. Write a function called generate_tag_report(notes: list[dict[str, str]]) -> str that takes a list of SmartNotes (each note is a dictionary with "title" and "tags" keys, where "tags" is a comma-separated string) and produces a formatted report.

Example input:

notes: list[dict[str, str]] = [
{"title": "AI Basics", "tags": "python,ai"},
{"title": "Web Dev", "tags": "python,web"},
{"title": "Testing 101", "tags": "testing"},
]

Expected output (as a single string):

Tag Report
----------
ai: 1 note(s)
python: 2 note(s)
testing: 1 note(s)
web: 1 note(s)

Requirements:

  • Split each note's "tags" string by comma to get individual tags
  • Count how many notes contain each tag
  • Sort tags alphabetically in the output
  • Return the report as a single string (use "\n" to join lines)

Write at least two test functions: one with the example above and one with a single note that has no tags (empty string for "tags").

Hint

Use a dict[str, int] to count tags. Loop through notes (outer), split tags and loop through each tag (inner). Then loop through the sorted dictionary keys to build the output lines. Handle the empty-tags case by checking if tag_string: before splitting.


Try With AI

Opening Claude Code

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 nested loops. Here is my understanding:
"When break runs inside a nested loop, it stops both the inner
and outer loop." Is this correct? What actually happens?

Read the AI's response carefully. It should correct the claim: break exits only the innermost loop. Compare its explanation to the flag-variable pattern you learned in this lesson.

Prompt 2: Generate and Review

Write a Python function called flatten_notes_tags that takes
all_tags: list[list[str]] and returns a single flat list[str]
with all tags. Use type annotations. Do not use list
comprehensions or itertools.

Review the output. Verify it uses a nested for loop with .append(). Check that every variable has a type annotation. If the AI used a shortcut you have not learned yet, ask it to rewrite using only the patterns from this lesson.

Prompt 3: Trace Table Challenge

Write a nested for loop that pairs each item from
["a", "b", "c"] with each item from [1, 2]. Show me a trace
table of every iteration, like the color-size example.

Count the rows in the AI's trace table. It should have 6 rows (3 x 2). If the count is wrong, correct the AI. This exercise builds your ability to evaluate AI output against a known formula.

What you're learning: You are verifying AI-generated trace tables against the multiplication rule (outer count x inner count = total iterations).


SmartNotes Connection

In SmartNotes, notes can have multiple tags and you often need to search, filter, or report across all of them. Nested loops are the fundamental tool for this kind of multi-level data processing. The find_shared_tags function from the Mastery Gate and the generate_tag_report from Exercise 3 are patterns you will use again in the Chapter 50 TDG.


Key Takeaways

  1. The inner loop runs completely for each outer step. If the outer loop runs 3 times and the inner runs 4 times, the inner body executes 12 times total. Multiply, do not add.

  2. Trace tables make nested execution visible. When you are confused about what a nested loop does, write a row for every iteration of the inner loop, tracking all variables.

  3. break exits only the innermost loop. The outer loop is unaffected. If you need to exit both loops, use a flag variable or move the inner loop into a function that returns early.

  4. Flatten with nested-for and .append(). To turn a list of lists into a single flat list, iterate through each inner list and append each element to an accumulator.

  5. Never modify a list while iterating over it. Build a new list with the items you want to keep. The original list stays untouched during iteration, and the result is predictable.


Looking Ahead

You can now write branches, loops, and nested loops. But how do you know your tests actually cover every path through a function? In Lesson 6, you will learn about branch coverage: a way to think systematically about which paths your tests exercise and which paths remain untested.