Skip to content
Home Python Python for Loop Explained — Syntax, Examples and Common Mistakes

Python for Loop Explained — Syntax, Examples and Common Mistakes

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Control Flow → Topic 2 of 7
Master the Python for loop from scratch.
🧑‍💻 Beginner-friendly — no prior Python experience needed
In this tutorial, you'll learn
Master the Python for loop from scratch.
  • A for loop visits every item in a collection exactly once — you describe what to do per item, Python handles the counting and advancing automatically.
  • range(stop) starts at 0 and excludes the stop value — range(5) gives 0,1,2,3,4. Use range(1, 6) when you need 1 through 5. range() is lazy — 48 bytes regardless of size.
  • Use enumerate() instead of a manual counter variable or range(len()) — it works on any iterable and is the Pythonic standard for indexed iteration.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • A for loop visits every item in an iterable and runs the same block of code for each — Python handles counting automatically
  • range(n) starts at 0 and excludes n — range(5) gives 0,1,2,3,4, never 5
  • enumerate() gives you both index and value — use it instead of a manual counter
  • zip() pairs items from two sequences together — use it instead of range(len()) when processing two lists in lockstep
  • break exits the loop; continue skips the current iteration; else runs only if no break occurred
  • Modifying a list while looping over it silently skips items — loop over a copy or build a new list
  • List comprehensions are faster and safer than a for loop that builds a new list — prefer them when the body is a single expression
  • Biggest trap: the for...else clause runs when the loop completes WITHOUT break, not when it breaks
Production IncidentSilent Data Loss from Modifying a List During IterationA data cleanup script skipped half the records it was supposed to delete, leaving corrupted entries in the production database.
SymptomCleanup job reported success but 47% of flagged records still existed in the database. No errors in logs. Script ran in under expected time.
AssumptionPython's for loop visits every item in the list sequentially regardless of modifications made during iteration.
Root causeThe script used for item in records: and called records.remove(item) inside the loop body when a condition matched. Removing an item shifts all subsequent indices left by one, causing Python's internal iterator to skip the next item. Every deletion caused one skip, resulting in approximately half the matching records being missed.
FixChanged to for item in records[:]: to iterate over a shallow copy while modifying the original list. The better long-term fix: replaced the entire pattern with a list comprehension — records = [r for r in records if not should_delete(r)] — which is both safer and expresses intent more clearly.
Key Lesson
Never modify a list while iterating over it directly — use a copy or build a new listSilent bugs are worse than crashes — always verify loop results with assertions or countsList comprehensions are both safer and more Pythonic than in-place modification loopsAdd logging that reports expected vs. actual item counts after cleanup operations
Production Debug GuideFrom silent skips to infinite hangs
Loop skips every other item during removalCheck if you are modifying the list in-place — use for item in list[:] or a list comprehension instead
range(n) produces unexpected starting numberRemember range starts at 0 by default — use range(1, n+1) if you need 1 through n
for...else block runs when you expect it to be skippedVerify the loop actually hits a break — else runs when the loop completes naturally, not when it breaks
NameError on loop variable after loop ends on empty listInitialize a result variable before the loop — never rely on the loop variable existing after the loop
zip() loop stops before one list is exhaustedzip() stops at the shortest sequence — use itertools.zip_longest() if you need to process all items from both sequences

Every real program does repetitive work. A weather app checks temperatures for 365 days. A music app loads every song in your library. An online store applies a discount to every item in your cart.

The for loop solves this elegantly. It lets you write an action once and Python automatically repeats it for every item in a collection. Python handles the counting and advancing for you — you just describe what to do on each visit.

By the end of this article you'll understand the for loop syntax, know how to iterate over lists, strings, dictionaries, ranges, and multiple sequences at once with zip(), use enumerate() like a professional, write list comprehensions instead of accumulator loops, and spot the mistakes that trip up nearly every beginner — including some that experienced developers still walk into.

What a for Loop Actually Does — The Core Idea

A for loop says: 'take this collection of things, visit each one in order, and run the same block of code for each.' Python handles all the counting and moving-forward for you. You just describe what to DO on each visit.

The collection you loop over can be a list of names, a string of characters, a range of numbers — almost anything that holds multiple items. Python calls these 'iterables', but you can think of them as 'anything you can step through one piece at a time.'

The basic shape of a for loop is always the same: the word for, then a variable name you choose (this is your 'current item' holder), then in, then the collection, then a colon. Everything indented below that runs once per item. The indentation is not optional — Python uses it to know which lines belong inside the loop.

Here is something worth understanding beyond the syntax: what Python is actually doing under the hood. When you write for planet in planets, Python calls iter(planets) to get an iterator object, then calls next() on it repeatedly until it runs out of items. You can do this manually if you want to see exactly what the loop sees. This also explains why you can loop over strings, files, and custom objects — anything that implements __iter__ and __next__ works, regardless of what it is.

first_for_loop.py · PYTHON
12345678910111213141516171819202122232425
# ── Basic for loop ───────────────────────────────────────────────────────────
planets = ["Mercury", "Venus", "Earth", "Mars", "Jupiter"]

for planet in planets:           # 'planet' holds the current item each round
    print(f"Visiting {planet}")  # this line runs once per planet

print("Tour complete!")          # NOT indented — runs after the loop finishes

# ── What Python actually does under the hood ─────────────────────────────────
# The for loop above is equivalent to this manual iterator protocol:
print("\n=== Manual iterator (what Python does internally) ===")
iterator = iter(planets)         # get an iterator from the list

while True:
    try:
        planet = next(iterator)  # ask for the next item
        print(f"Visiting {planet}")
    except StopIteration:        # no more items — loop ends
        break

# Knowing this explains WHY you can loop over strings, files, generators:
# any object that implements __iter__ and __next__ is fair game.
print("\n=== Strings are iterable too ===")
for char in "Mars":             # string is just a sequence of characters
    print(char)
▶ Output
Visiting Mercury
Visiting Venus
Visiting Earth
Visiting Mars
Visiting Jupiter
Tour complete!

=== Manual iterator (what Python does internally) ===
Visiting Mercury
Visiting Venus
Visiting Earth
Visiting Mars
Visiting Jupiter

=== Strings are iterable too ===
M
a
r
s
Mental Model
The Iterable Mental Model
Think of a for loop as a conveyor belt — items come to you one at a time, and you do the same thing to each before it moves on.
  • Python calls iter() on the collection to get an iterator object
  • Each iteration calls next() to get the next item — StopIteration ends the loop
  • The loop variable is assigned fresh on each iteration — it does not persist state between rounds
  • Any object that implements __iter__ and __next__ can be looped over — not just lists
  • Indentation defines the loop body — no braces needed, Python enforces structure visually
📊 Production Insight
Python's for loop works on any iterable, not just lists.
Strings, tuples, dictionaries, files, generators, and database query results are all iterable.
Rule: if you can use 'in' with it in a membership test, you can loop over it.
Understanding iter() and next() is what separates developers who debug iterator errors from developers who are confused by them.
🎯 Key Takeaway
A for loop visits every item in an iterable — Python handles the counting.
The loop variable is assigned fresh each iteration — it holds the current item only.
Any object implementing __iter__ can be looped over — understanding iter() and next() makes debugging iterator errors straightforward.
Choosing What to Loop Over
IfNeed to process every item in a list
UseUse for item in my_list — direct iteration is cleanest
IfNeed to repeat something N times without caring about the items
UseUse for _ in range(n) — underscore signals the variable is unused
IfNeed both index and value from a list
UseUse for i, item in enumerate(my_list) — not a manual counter
IfNeed to loop over dictionary keys and values together
UseUse for key, value in my_dict.items() — direct dict iteration only gives keys

Looping Over Numbers with range() — Your Most-Used Tool

Most beginners need to repeat something a set number of times — run a countdown from 10, process 100 items, print a multiplication table. For that, Python gives you range(). It generates a sequence of numbers on demand without storing them all in memory.

range(5) gives you 0, 1, 2, 3, 4 — five numbers starting at zero. This trips up almost every beginner: range counts from zero by default, and the number you pass in is NOT included. Think of it as 'up to but not including 5.'

range(start, stop) lets you control where counting begins. range(1, 6) gives 1, 2, 3, 4, 5. range(start, stop, step) adds a step — range(0, 10, 2) gives you every even number from 0 to 8. You can even count backwards with a negative step: range(5, 0, -1) counts down from 5 to 1.

One thing worth knowing: range() is a lazy object in Python 3. It does not generate all the numbers upfront and store them in a list. It calculates each number on the fly as the loop requests it. range(1_000_000) uses a few dozen bytes of memory regardless of the size. This is why you should never write list(range(n)) just to iterate over numbers — you're forcing Python to materialise the entire sequence into memory for no reason.

range_examples.py · PYTHON
123456789101112131415161718192021222324252627282930
# ── range(n): repeat N times, starting from 0 ────────────────────────────────
print("=== Countdown prep ===")
for step_number in range(5):           # generates 0, 1, 2, 3, 4
    print(f"Step {step_number}")

# ── range(start, stop): control where counting begins ────────────────────────
print("\n=== Lap counter ===")
for lap in range(1, 6):                # generates 1, 2, 3, 4, 5 (NOT 6)
    print(f"Lap {lap} complete")

# ── range(start, stop, step): skip values ────────────────────────────────────
print("\n=== Even floors only ===")
for floor in range(0, 11, 2):          # 0, 2, 4, 6, 8, 10
    print(f"Elevator stops at floor {floor}")

# ── Counting backwards ────────────────────────────────────────────────────────
print("\n=== Rocket launch ===")
for countdown in range(5, 0, -1):      # 5, 4, 3, 2, 1
    print(f"T-minus {countdown}...")
print("Liftoff!")

# ── range() is lazy — not a list ─────────────────────────────────────────────
import sys

lazy_range   = range(1_000_000)
eager_list   = list(range(1_000_000))

print(f"\nrange(1_000_000) memory: {sys.getsizeof(lazy_range)} bytes")
print(f"list(range(1_000_000)) memory: {sys.getsizeof(eager_list):,} bytes")
# Never convert range to a list just to iterate — there is no benefit
▶ Output
=== Countdown prep ===
Step 0
Step 1
Step 2
Step 3
Step 4

=== Lap counter ===
Lap 1 complete
Lap 2 complete
Lap 3 complete
Lap 4 complete
Lap 5 complete

=== Even floors only ===
Elevator stops at floor 0
Elevator stops at floor 2
Elevator stops at floor 4
Elevator stops at floor 6
Elevator stops at floor 8
Elevator stops at floor 10

=== Rocket launch ===
T-minus 5...
T-minus 4...
T-minus 3...
T-minus 2...
T-minus 1...
Liftoff!

range(1_000_000) memory: 48 bytes
list(range(1_000_000)) memory: 8,000,056 bytes
⚠ Watch Out: range(5) Starts at 0, Not 1
This is the single most common beginner mistake. range(5) gives you [0, 1, 2, 3, 4] — five values, but none of them is 5. If you need to print 1 through 5, use range(1, 6). A good mental model: the stop value is a wall the counter hits but never crosses. The memory comparison above also shows why you should never write list(range(n)) just to loop — range is already lazy and memory-efficient on its own.
📊 Production Insight
range() is a lazy sequence object — it does not store all numbers in memory.
range(1_000_000) uses 48 bytes regardless of the stop value. list(range(1_000_000)) uses 8MB.
Rule: always use range() directly for looping — never convert it to a list first unless you genuinely need random access or multiple passes.
🎯 Key Takeaway
range(n) starts at 0 and excludes n — it never includes the stop value.
range() is lazy — 48 bytes of memory regardless of how large the range is. Never convert to list just to iterate.
The stop value is a wall the counter hits but never crosses — always add 1 when you need to include it.
Choosing range() Parameters
IfNeed to repeat something N times starting from 0
UseUse range(n) — gives 0 through n-1
IfNeed numbers 1 through N for display or business logic
UseUse range(1, n+1) — stop is exclusive, so add 1
IfNeed every other element or a custom step
UseUse range(start, stop, step) — step can be negative for reverse
IfNeed to loop in reverse from N down to 1
UseUse range(n, 0, -1) — or reversed(my_list) for reversing an existing sequence

Looping Over Strings, Dictionaries, and Using enumerate()

A string in Python is just a sequence of characters, and you can loop over it the same way you loop over a list. Python will hand you one character at a time. This is useful for tasks like counting vowels, reversing text, or scanning for special characters.

Dictionaries are a bit different. When you loop over a dictionary directly, you get its keys. To get both keys and values together, use .items() — it hands you both as a pair each round. If you only need the values, use .values(). If you only need the keys, direct iteration or .keys() are equivalent — .keys() is slightly more explicit and signals intent to whoever reads the code next.

Here's a power move: enumerate(). Often you need both the item AND its position number. Without enumerate, beginners create a separate counter variable and increment it manually — messy and error-prone. enumerate(collection) hands you a tuple of (index, item) on each pass. You unpack it by naming two variables after for. The start parameter lets you control the starting index number, which is handy when you want position 1 instead of 0 for display purposes.

One more pattern that eliminates a whole class of bugs: if you discover yourself writing for i in range(len(my_list)): and then accessing my_list[i] inside the loop, stop. That's the non-Pythonic way to get an index. Use enumerate() instead — it works with any iterable, handles the index arithmetic for you, and makes the code readable.

advanced_for_loops.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# ── Looping over a string ─────────────────────────────────────────────────────
word = "Python"
print("=== Characters in 'Python' ===")
for character in word:               # each character, one at a time
    print(character)

# ── Looping over a dictionary ─────────────────────────────────────────────────
student_grades = {
    "Alice": 92,
    "Bob": 78,
    "Carlos": 85
}
print("\n=== Keys only (direct iteration) ===")
for student_name in student_grades:          # gives keys by default
    print(student_name)

print("\n=== Keys AND values (.items()) ===")
for student_name, grade in student_grades.items():
    if grade >= 90:
        remark = "Excellent"
    elif grade >= 80:
        remark = "Good"
    else:
        remark = "Keep practising"
    print(f"{student_name}: {grade} — {remark}")

print("\n=== Values only (.values()) ===")
total = sum(student_grades.values())   # .values() in a built-in — no loop needed
print(f"Class total: {total}")

# ── Using enumerate() to track position ───────────────────────────────────────
race_finishers = ["Aisha", "Ben", "Clara", "David"]
print("\n=== Race Results — enumerate(start=1) ===")
for position, runner_name in enumerate(race_finishers, start=1):
    print(f"Position {position}: {runner_name}")

# ── The anti-pattern enumerate() replaces ─────────────────────────────────────
print("\n=== Avoid this pattern: range(len()) ===")
# BAD — unidiomatic, error-prone, breaks on non-sequence iterables
for i in range(len(race_finishers)):
    print(f"{i+1}: {race_finishers[i]}")

print("\n=== Prefer enumerate() instead ===")
# GOOD — clean, works on any iterable, no manual index math
for i, runner in enumerate(race_finishers, start=1):
    print(f"{i}: {runner}")
▶ Output
=== Characters in 'Python' ===
P
y
t
h
o
n

=== Keys only (direct iteration) ===
Alice
Bob
Carlos

=== Keys AND values (.items()) ===
Alice: 92 — Excellent
Bob: 78 — Keep practising
Carlos: 85 — Good

=== Values only (.values()) ===
Class total: 255

=== Race Results — enumerate(start=1) ===
Position 1: Aisha
Position 2: Ben
Position 3: Clara
Position 4: David

=== Avoid this pattern: range(len()) ===
1: Aisha
2: Ben
3: Clara
4: David

=== Prefer enumerate() instead ===
1: Aisha
2: Ben
3: Clara
4: David
💡Pro Tip: Always Prefer enumerate() Over a Manual Counter
If you find yourself writing counter = 0 before a loop and counter += 1 inside it, stop. That's exactly what enumerate() exists to replace. Similarly, if you're writing for i in range(len(my_list)): just to access my_list[i], that's a signal to switch to enumerate(). It's cleaner, less likely to have off-by-one bugs, and works with any iterable — not just lists that have a len().
📊 Production Insight
Looping over a dict directly only gives keys — use .items() for key-value pairs.
Forgetting .items() is a silent bug: the code runs but processes only keys, not values.
The range(len(my_list)) pattern is the most common sign that someone learned Python from a C or Java background — it works, but it's fighting the language instead of using it.
Rule: use enumerate() for indexed list access, dict.items() for key-value iteration. Both are O(1) overhead over direct iteration.
🎯 Key Takeaway
enumerate() replaces manual counter variables and the range(len()) pattern — always prefer it.
Direct dict iteration gives keys only — use .items() when you need values, .values() when you only need values.
range(len(my_list)) is the C-style index loop — it works but it's not Pythonic and breaks on non-sequence iterables.
Choosing the Right Iteration Method
IfNeed only values from a list
UseUse for item in my_list — simplest form
IfNeed index and value from a list
UseUse for i, item in enumerate(my_list) — never use range(len())
IfNeed only keys from a dictionary
UseUse for key in my_dict — direct iteration gives keys by default
IfNeed keys and values from a dictionary
UseUse for key, value in my_dict.items() — .items() returns (key, value) pairs

Looping Over Two Sequences at Once with zip()

One of the most common loop patterns you will write in real code is processing two related lists in lockstep — pairing a list of names with a list of scores, pairing a list of keys with a list of values, or zipping two data frames together row by row. The wrong way to do this is for i in range(len(names)): name = names[i]; score = scores[i]. The right way is zip().

zip() takes two or more iterables and pairs their items together, one from each, yielding a tuple per step. When the shorter iterable runs out, zip() stops — this is the default behaviour and catches a lot of people off guard. If your two lists might have different lengths and you need to process all items from both, use itertools.zip_longest() from the standard library, which fills in a default value for the shorter sequence.

Like range(), zip() is lazy in Python 3. It doesn't build a list of tuples upfront — it generates pairs on demand. zip(list_of_a_million, list_of_a_million) uses constant memory because it processes one pair at a time.

zip() also makes the code self-documenting. When someone reads for name, score in zip(names, scores), the intent is immediately obvious. The index-based alternative requires the reader to mentally verify that names[i] and scores[i] are actually the right pairing — an extra cognitive step that zip() eliminates entirely.

zip_examples.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445
from itertools import zip_longest

# ── Basic zip(): pair two lists ───────────────────────────────────────────────
names  = ["Alice", "Bob", "Carlos", "Diana"]
scores = [92, 78, 85, 96]

print("=== Score Report ===")
for name, score in zip(names, scores):   # no index math, no range(len())
    status = "Pass" if score >= 80 else "Fail"
    print(f"{name}: {score} — {status}")

# ── zip() stops at the shorter sequence ───────────────────────────────────────
extra_names = ["Alice", "Bob", "Carlos", "Diana", "Evan"]  # 5 names
fewer_scores = [92, 78, 85]                                  # 3 scores

print("\n=== zip() stops early — Evan and Diana are silently dropped ===")
for name, score in zip(extra_names, fewer_scores):
    print(f"{name}: {score}")
# Evan and Diana never appear — zip() stopped when fewer_scores ran out

# ── zip_longest(): process all items, fill gaps with a default ────────────────
print("\n=== zip_longest() — no items dropped ===")
for name, score in zip_longest(extra_names, fewer_scores, fillvalue="N/A"):
    print(f"{name}: {score}")

# ── zip() with three sequences ────────────────────────────────────────────────
students = ["Alice", "Bob", "Carlos"]
grades   = ["A",     "C",   "B"    ]
years    = [2024,    2025,  2026   ]

print("\n=== Three-way zip ===")
for student, grade, year in zip(students, grades, years):
    print(f"{student} ({year}): {grade}")

# ── zip() to build a dictionary from two lists ────────────────────────────────
headers = ["name", "score", "year"]
row     = ["Alice", 92, 2026]

record = dict(zip(headers, row))      # cleaner than a manual loop
print(f"\n=== dict from zip: {record}")

# ── zip() is lazy: constant memory regardless of input size ──────────────────
import sys
large_zip = zip(range(1_000_000), range(1_000_000))
print(f"\nzip object memory: {sys.getsizeof(large_zip)} bytes — not 8MB")
▶ Output
=== Score Report ===
Alice: 92 — Pass
Bob: 78 — Fail
Carlos: 85 — Pass
Diana: 96 — Pass

=== zip() stops early — Evan and Diana are silently dropped ===
Alice: 92
Bob: 78
Carlos: 85

=== zip_longest() — no items dropped ===
Alice: 92
Bob: 78
Carlos: 85
Diana: N/A
Evan: N/A

=== Three-way zip ===
Alice (2024): A
Bob (2025): C
Carlos (2026): B

=== dict from zip: {'name': 'Alice', 'score': 92, 'year': 2026}

zip object memory: 72 bytes — not 8MB
⚠ Watch Out: zip() Silently Drops Items from the Longer Sequence
If your two lists have different lengths, zip() stops as soon as the shorter one runs out — no error, no warning. The extra items from the longer list are silently ignored. This is the right behaviour when you know your lists are the same length, but a silent data loss bug when they aren't. If there's any chance the lengths differ, use itertools.zip_longest() and handle the fill value explicitly.
📊 Production Insight
zip() silently drops items from the longer sequence — this is the second most common silent data loss pattern after modifying a list during iteration.
If you're pairing user records with transaction records and one list has 10,001 entries, the 10,001st entry is quietly ignored.
Rule: when pairing data from two sources, assert they're the same length before zipping — or use zip_longest with explicit fill handling.
🎯 Key Takeaway
zip() pairs items from two or more sequences without index math — it's the replacement for range(len()) when processing parallel lists.
zip() stops at the shorter sequence — silently drops items from the longer one. Use zip_longest() when lengths may differ.
zip() is lazy — constant memory regardless of input size, just like range().
Choosing Between zip() and zip_longest()
IfPairing two lists you control and know are the same length
UseUse zip() — clean, lazy, and self-documenting
IfPairing data from two external sources that might differ in length
UseUse zip_longest() from itertools with an explicit fillvalue — never silently lose data
IfBuilding a dictionary from two lists (keys and values)
UseUse dict(zip(keys, values)) — the idiomatic one-liner
IfPairing items from three or more sequences
Usezip() accepts any number of iterables — for name, score, year in zip(a, b, c)

Controlling the Loop — break, continue, and the else Clause

Sometimes you don't want to visit every single item. Maybe you're searching a list for a specific value and want to stop the moment you find it — no point checking the rest. That's break. It exits the loop immediately, as if you slammed a book shut.

continue is the opposite of a full stop — it's more of a skip. When Python hits continue, it abandons the current iteration and jumps straight to the next one. Useful when you want to process most items but ignore ones that fail a condition.

Python's for loop also has an else clause — and this genuinely surprises most people. The else block runs only if the loop completed normally, meaning it was NOT interrupted by a break. It's perfect for 'search and report' patterns: loop through items looking for something, break if found, and use else to handle the 'not found' case cleanly.

One thing the decision tree below mentions that's worth expanding on: break and continue only affect the innermost loop they're inside. In a nested loop, break exits the inner loop, not the outer one. If you need to exit both loops at once, the cleanest approach is to move the nested logic into a function and use return to exit cleanly. The flag variable approach works but adds noise that a function call eliminates.

break_continue_else.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
# ── break: stop the moment you find what you need ────────────────────────────
passenger_list = ["Tom", "Sara", "Jake", "Mia", "Leo"]
target_passenger = "Jake"

print("=== Boarding check ===")
for passenger in passenger_list:
    if passenger == target_passenger:
        print(f"Found {target_passenger}! Stopping search.")
        break
    print(f"Not {target_passenger}, checked {passenger}")

# ── continue: skip invalid items, keep going ──────────────────────────────────
raw_scores = [88, -5, 76, 0, 95, -1, 60]
print("\n=== Valid scores only ===")
for score in raw_scores:
    if score <= 0:       # invalid — skip
        continue
    print(f"Recording score: {score}")

# ── for...else: runs ONLY if the loop was NOT broken out of ───────────────────
seating_chart = ["14A", "14B", "15A", "15B"]
requested_seat = "16C"

print("\n=== Seat search ===")
for seat in seating_chart:
    if seat == requested_seat:
        print(f"Seat {requested_seat} found!")
        break
else:
    # This block runs ONLY because we never hit 'break'
    print(f"Seat {requested_seat} is not available.")

# ── break only affects the innermost loop ─────────────────────────────────────
# If you need to exit a nested loop early, use a function + return
print("\n=== Nested loop: break exits inner only ===")
found = False
for row in range(3):
    for col in range(3):
        if row == 1 and col == 1:
            print(f"Found target at row={row}, col={col}")
            found = True
            break          # exits inner loop only
    if found:
        break              # flag lets outer loop exit too

# Cleaner approach: extract nested search into a function
print("\n=== Nested loop: function + return is cleaner ===")
def find_in_grid(grid, target):
    for r, row in enumerate(grid):
        for c, value in enumerate(row):
            if value == target:
                return r, c   # exits both loops immediately
    return None

grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
result = find_in_grid(grid, 5)
print(f"Found 5 at: {result}")
▶ Output
=== Boarding check ===
Not Jake, checked Tom
Not Jake, checked Sara
Found Jake! Stopping search.

=== Valid scores only ===
Recording score: 88
Recording score: 76
Recording score: 95
Recording score: 60

=== Seat search ===
Seat 16C is not available.

=== Nested loop: break exits inner only ===
Found target at row=1, col=1

=== Nested loop: function + return is cleaner ===
Found 5 at: (1, 1)
🔥Interview Gold: The for...else Clause
Most Python developers — including experienced ones — forget that for loops can have an else. Interviewers love asking about this because it shows you've gone beyond the basics. Remember: the else runs when the loop finishes without a break. Think of it as 'completed without interruption.' The nested loop exit pattern — where break only exits the innermost loop — is equally important to know. The cleanest solution is almost always to move the search into a separate function and use return.
📊 Production Insight
break and continue only affect the loop they are directly inside.
In nested loops, break in the inner loop does NOT exit the outer loop.
The flag variable pattern works but adds cognitive overhead — a search function with return is cleaner and testable in isolation.
Rule: if your nested loop exit logic is more than two lines, it belongs in a function.
🎯 Key Takeaway
break = stop the entire loop. continue = skip this iteration, keep going.
The for...else clause runs only when the loop completes WITHOUT break — 'completed without interruption.'
break only exits the innermost loop — use a function with return to cleanly exit nested loops.
break vs. continue vs. else Selection
IfFound what you were searching for — stop now
UseUse break — exits the loop immediately, no more iterations
IfCurrent item should be skipped but loop continues
UseUse continue — jumps to the next iteration immediately
IfNeed to handle the case where nothing was found
UseUse for...else — else runs only if the loop completed without break
IfNeed to exit an outer loop from inside a nested loop
UseMove the nested logic into a function and use return — cleaner than a flag variable and independently testable

List Comprehensions — When a for Loop Fits on One Line

A list comprehension is a compact way to create a new list by processing or filtering items from an existing collection. It's not a separate topic from for loops — it IS a for loop, just expressed in a single readable line. Python evaluates it by running the loop internally and collecting results.

The anatomy: [expression for item in iterable] — or with a filter: [expression for item in iterable if condition]. The result is always a new list. The original is untouched.

This matters because the most common for loop pattern in Python is the 'accumulator' — create an empty list, loop, append items conditionally. That pattern has three moving parts, each of which can be written wrong. The list comprehension collapses all three into one expression that's harder to get wrong.

Beyond lists, Python has dict comprehensions ({k: v for k, v in items}), set comprehensions ({x for x in items}), and generator expressions ((x for x in items)). Generator expressions are the memory-efficient version — they produce one item at a time rather than building the whole list in memory. If you're passing the result directly to a function like sum(), max(), or any(), use a generator expression instead of a list comprehension — there's no reason to materialise the entire list just to discard it immediately.

list_comprehensions.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243
# ── The accumulator loop pattern (fine, but verbose) ─────────────────────────
raw_scores = [88, -5, 76, 0, 95, -1, 60, 45, 101]

valid_scores_loop = []
for score in raw_scores:
    if 0 < score <= 100:
        valid_scores_loop.append(score)

print(f"Loop result:   {valid_scores_loop}")

# ── The same logic as a list comprehension ────────────────────────────────────
valid_scores_comp = [score for score in raw_scores if 0 < score <= 100]
print(f"Comprehension: {valid_scores_comp}")

# ── Transformation: apply an operation to every item ─────────────────────────
temps_celsius    = [0, 20, 37, 100]
temps_fahrenheit = [(c * 9/5) + 32 for c in temps_celsius]
print(f"\nFahrenheit: {temps_fahrenheit}")

# ── Filter AND transform in one line ─────────────────────────────────────────
words   = ["hello", "world", "python", "for", "loop"]
# Uppercase words longer than 4 characters
result  = [w.upper() for w in words if len(w) > 4]
print(f"\nFiltered + uppercased: {result}")

# ── Dict comprehension ────────────────────────────────────────────────────────
students = {"Alice": 92, "Bob": 78, "Carlos": 85}
passing  = {name: grade for name, grade in students.items() if grade >= 80}
print(f"\nPassing students: {passing}")

# ── Generator expression vs list comprehension: memory comparison ─────────────
import sys

list_comp = [x ** 2 for x in range(10_000)]        # builds entire list in memory
gen_expr  = (x ** 2 for x in range(10_000))         # lazy — one value at a time

print(f"\nList comprehension size: {sys.getsizeof(list_comp):,} bytes")
print(f"Generator expression size: {sys.getsizeof(gen_expr)} bytes")

# When summing, use a generator — no need to build the list
list_sum = sum([x ** 2 for x in range(10_000)])    # builds list, then discards it
gen_sum  = sum(x ** 2 for x in range(10_000))      # constant memory — prefer this
print(f"\nBoth produce same sum: {list_sum == gen_sum}")
▶ Output
Loop result: [88, 76, 95, 60, 45]
Comprehension: [88, 76, 95, 60, 45]

Fahrenheit: [32.0, 68.0, 98.6, 212.0]

Filtered + uppercased: ['HELLO', 'WORLD', 'PYTHON']

Passing students: {'Alice': 92, 'Carlos': 85}

List comprehension size: 87,616 bytes
Generator expression size: 112 bytes

Both produce same sum: True
Mental Model
List Comprehension vs Generator Expression: The Memory Rule
If you need a list you'll use multiple times or access by index, use a list comprehension. If you're iterating once and feeding the result to sum(), max(), any(), or similar, use a generator expression — it uses constant memory.
  • List comprehension: [expr for item in iterable] — builds the full list immediately, random access, reusable
  • Generator expression: (expr for item in iterable) — lazy, one item at a time, cannot index into it
  • The outer parentheses in sum(x**2 for x in range(n)) make it a generator, not a list — one pair of parentheses is enough
  • If the comprehension body is more than one expression or needs multiple lines, use a regular for loop — comprehensions should stay readable
  • Nested list comprehensions (list of lists) are powerful but hard to read — use a named for loop when nesting exceeds one level
📊 Production Insight
The most common Python performance mistake I see in code reviews is sum([x for x in large_list if condition]) — building a full list in memory just to sum it and throw it away.
The fix is one character: remove the square brackets. sum(x for x in large_list if condition) is a generator expression — constant memory, same result.
Rule: if you are passing a comprehension directly to sum(), any(), all(), max(), or min(), drop the square brackets.
🎯 Key Takeaway
A list comprehension is a for loop expressed in one line — it builds a new list by transforming or filtering items.
Generator expressions use constant memory — prefer them when passing results directly to sum(), any(), all(), max().
Use a regular for loop when the body has side effects — comprehensions are for producing values, not performing actions.
For Loop vs List Comprehension vs Generator Expression
IfBuilding a new list by transforming or filtering an existing one
UseUse a list comprehension — cleaner and faster than an accumulator loop
IfPassing the result directly to sum(), max(), any(), all()
UseUse a generator expression (round brackets) — avoids building a list that gets immediately discarded
IfThe loop body has side effects (printing, writing to file, calling APIs)
UseUse a regular for loop — comprehensions should be pure transformations, not side-effect machines
IfBuilding a dictionary from an iterable
UseUse a dict comprehension: {key: value for key, value in items}

Nested Loops and When to Avoid Them

A nested loop is a loop inside another loop. The outer loop runs once; for each of its iterations, the inner loop runs to completion. If the outer loop has 5 items and the inner loop has 5 items, you get 25 total iterations. If both loops have 100 items, you get 10,000. This O(n²) growth is the thing to watch — nested loops that seem fine on small data sets can grind to a halt on production data.

The canonical use case for a nested loop is working with two-dimensional data: a grid, a matrix, a table. You loop over rows in the outer loop and columns in the inner loop. That's genuinely the right tool.

The anti-pattern is using a nested loop when Python already has a built-in that does the job in one pass. Checking if any item from list A exists in list B with nested loops is O(n²). Converting list B to a set first reduces the inner 'in' check to O(1), making the overall algorithm O(n). That's the difference between a query that runs in 2ms and one that runs in 12 seconds on a million-row dataset.

For generating every combination of two sequences — every pair, every permutation — use itertools.product() instead of nested loops. It produces the same result with less code and makes the intent explicit: 'I want the cartesian product of these two sequences.'

nested_loops.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
from itertools import product

# ── Basic nested loop: multiplication table ───────────────────────────────────
print("=== Multiplication Table (3x3) ===")
for row in range(1, 4):         # outer loop: 3 iterations
    for col in range(1, 4):     # inner loop: 3 iterations per outer iteration
        print(f"{row} x {col} = {row * col}")
    print()                     # blank line after each row

# ── Working with a 2D grid ────────────────────────────────────────────────────
print("=== Grid traversal ===")
grid = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]
for row_index, row in enumerate(grid):
    for col_index, value in enumerate(row):
        print(f"  grid[{row_index}][{col_index}] = {value}")

# ── The O(n²) anti-pattern: nested loop membership check ─────────────────────
import time

list_a = list(range(5_000))
list_b = list(range(2_500, 7_500))  # overlaps with list_a

# BAD: O(n²) — checks every item in list_b for every item in list_a
start = time.perf_counter()
bad_overlap = [x for x in list_a if x in list_b]   # 'in list_b' is O(n) each time
bad_time = time.perf_counter() - start

# GOOD: O(n) — convert list_b to a set first, then check is O(1)
set_b = set(list_b)
start = time.perf_counter()
good_overlap = [x for x in list_a if x in set_b]   # 'in set_b' is O(1) each time
good_time = time.perf_counter() - start

print(f"\n=== Membership check performance ===")
print(f"Nested loop (O(n²)): {bad_time*1000:.2f}ms, {len(bad_overlap)} overlapping items")
print(f"Set lookup  (O(n)):  {good_time*1000:.2f}ms, {len(good_overlap)} overlapping items")

# ── itertools.product(): cartesian product without nested loops ───────────────
colors  = ["Red", "Blue"]
sizes   = ["S",   "M",   "L"  ]

print("\n=== All colour/size combinations ===")
for color, size in product(colors, sizes):   # same result as nested loop
    print(f"{color} - {size}")
▶ Output
=== Multiplication Table (3x3) ===
1 x 1 = 1
1 x 2 = 2
1 x 3 = 3

2 x 1 = 2
2 x 2 = 4
2 x 3 = 6

3 x 1 = 3
3 x 2 = 6
3 x 3 = 9

=== Grid traversal ===
grid[0][0] = 1
grid[0][1] = 2
grid[0][2] = 3
grid[1][0] = 4
grid[1][1] = 5
grid[1][2] = 6
grid[2][0] = 7
grid[2][1] = 8
grid[2][2] = 9

=== Membership check performance ===
Nested loop (O(n²)): 187.43ms, 2500 overlapping items
Set lookup (O(n)): 0.61ms, 2500 overlapping items

=== All colour/size combinations ===
Red - S
Red - M
Red - L
Blue - S
Blue - M
Blue - L
Mental Model
When Nested Loops Are the Wrong Tool
If your inner loop is checking membership ('is this item in that list?'), you almost certainly want a set instead. A nested loop is O(n²). A set lookup is O(1).
  • Nested loops on 100-item lists = 10,000 operations. On 10,000-item lists = 100,000,000 operations.
  • If the inner loop body is just an 'in' check, convert the outer collection to a set first
  • itertools.product() generates cartesian products without nested loop syntax — more readable and explicit
  • Deeply nested loops (3+ levels) are almost always a sign the code needs to be broken into functions
  • Profile before optimising — sometimes the O(n²) loop is fine because n is always small
📊 Production Insight
The set conversion trick — set_b = set(list_b) before the loop — is one of the highest-impact single-line performance fixes in Python.
I've seen data pipelines go from 40 minutes to 12 seconds by converting one list membership check to a set lookup.
Rule: if you are checking 'x in list' inside a loop, convert the list to a set first. The one-time O(n) conversion cost pays for itself after the first iteration.
🎯 Key Takeaway
Nested loops on large data are O(n²) — watch the sizes of what you're iterating.
Convert a list to a set before membership checks inside a loop — O(n) conversion once, O(1) lookup per iteration.
Use itertools.product() for cartesian products instead of nested loops — same result, explicit intent.
Three or more levels of nesting is a signal to refactor — not a style preference, a readability and testability requirement.
Nested Loop vs Built-in Alternative
IfProcessing a 2D grid, matrix, or table row by row, column by column
UseNested loops are the right tool — enumerate() both levels for clean index tracking
IfChecking which items from list A appear in list B
UseConvert list B to a set, then use a single list comprehension: [x for x in a if x in set_b]
IfGenerating every combination of items from two sequences
UseUse itertools.product(a, b) — avoids nested loop syntax and makes intent explicit
IfLoop is nested 3+ levels deep
UseRefactor — break inner loop logic into a named function. Three levels of nesting is almost always a design problem.
🗂 Loop Type Comparison
When to use for vs. while in Python
Aspectfor Loopwhile Loop
Best use caseKnown collection or fixed number of iterationsRepeat until a condition changes (unknown count)
Risk of infinite loopVery low — stops when collection is exhaustedHigh — easy to forget updating the condition
Counting built-inYes, via range() or enumerate()No — you manage your own counter
ReadabilityVery readable — intent is obviousCan be harder to scan quickly
Looping over a listNatural — for item in my_listPossible but awkward with index management
Typical exampleProcess every order in a cartKeep prompting user until valid input received
Memory efficiencyrange() and zip() are lazy — constant memoryNo built-in lazy generation
Parallel iterationzip() pairs two sequences cleanlyRequires manual index management for both sequences

🎯 Key Takeaways

  • A for loop visits every item in a collection exactly once — you describe what to do per item, Python handles the counting and advancing automatically.
  • range(stop) starts at 0 and excludes the stop value — range(5) gives 0,1,2,3,4. Use range(1, 6) when you need 1 through 5. range() is lazy — 48 bytes regardless of size.
  • Use enumerate() instead of a manual counter variable or range(len()) — it works on any iterable and is the Pythonic standard for indexed iteration.
  • Use zip() to iterate over two sequences in lockstep — it is the replacement for range(len()) on parallel lists. zip() stops at the shorter sequence; use itertools.zip_longest() when lengths may differ.
  • The for...else clause runs only if the loop was NOT exited via break — it's Python's clean built-in pattern for 'search and handle not-found' logic.
  • Never modify a list while iterating over it directly — use a copy or list comprehension. List comprehensions are safer, faster to write, and express intent more clearly than accumulator loops.
  • Nested loops are O(n²) — if your inner loop is an 'in list' check, convert the list to a set first. One O(n) set conversion pays for itself after the first iteration.
  • Generator expressions use constant memory — when passing results to sum(), any(), all(), max(), drop the square brackets and let Python be lazy.

⚠ Common Mistakes to Avoid

    Modifying a list while looping over it directly
    Symptom

    Loop silently skips every other item. Cleanup scripts report success but leave half the target records untouched. No errors, no warnings — just wrong results.

    Fix

    Loop over a copy with for item in my_list[:] or build a new list with a list comprehension: cleaned = [item for item in my_list if not should_remove(item)]. The list comprehension is the preferred approach — it's safer, more readable, and expresses intent directly.

    Expecting range(n) to include n in its output
    Symptom

    Calculations are off by one. A loop meant to process items 1 through 10 only processes 1 through 9. The last item is always silently missing.

    Fix

    Remember range's stop value is exclusive. Use range(1, n+1) to get 1 through n. Think of stop as a wall the counter hits but never crosses.

    Using the loop variable after the loop and trusting its value
    Symptom

    NameError when the loop iterates over an empty list (variable was never created). Or the variable holds the last item, not a meaningful result, leading to logic errors.

    Fix

    Initialize a result variable before the loop and store results explicitly. Never rely on the loop variable existing or holding a meaningful value after the loop ends.

    Forgetting that for...else runs when the loop does NOT break
    Symptom

    else block executes when you expect it to be skipped. The logic is inverted from what most developers assume — else means 'completed normally', not 'found nothing'.

    Fix

    Read for...else as: 'if we finish the loop without hitting break, run this.' It is NOT an if-else — it is a loop completion handler.

    Using range(len(my_list)) instead of enumerate() for indexed iteration
    Symptom

    Code works but is harder to read, breaks on iterables without len(), and introduces off-by-one opportunities with manual index arithmetic.

    Fix

    Use for i, item in enumerate(my_list) — it works on any iterable, handles the index automatically, and reads as natural English. The range(len()) pattern is the Java/C index loop imported into Python where it doesn't belong.

    Using zip() when the two sequences might have different lengths
    Symptom

    Items from the longer sequence are silently dropped. No error. The last N records in the longer list are never processed, causing data loss that only surfaces later.

    Fix

    Use itertools.zip_longest(a, b, fillvalue=None) when sequence lengths might differ. Add an assertion before zipping if they must be equal: assert len(a) == len(b), f'Length mismatch: {len(a)} vs {len(b)}'.

Interview Questions on This Topic

  • QWhat is the difference between break and continue in a Python for loop, and can you give a practical example of when you'd use each?JuniorReveal
    break exits the loop entirely — no more iterations run. Use it when searching for a specific item and you've found it. continue skips only the rest of the current iteration and jumps to the next one. Use it when you want to process most items but skip ones that fail a condition. Example: use break when you find a target username in a list; use continue to skip negative scores when computing an average. Important: both break and continue only affect the innermost loop they are inside — in nested loops, break exits the inner loop only.
  • QPython's for loop has an else clause — what does it do, and when does the else block actually execute versus when does it get skipped?Mid-levelReveal
    The else block runs only if the for loop completes all iterations WITHOUT being interrupted by a break. If break is hit at any point, the else block is skipped entirely. This makes it ideal for search patterns: loop through items, break if found, and use else to handle the 'not found' case. Think of it as 'completed without interruption.' It is one of the most commonly forgotten Python features and appears regularly in interviews because it signals you've gone beyond surface-level knowledge.
  • QIf you need both the index and the value when iterating over a list, what are two ways to do it and which is considered more Pythonic — and why?JuniorReveal
    Two ways: (1) Use enumerate(my_list) which yields (index, value) tuples — this is the Pythonic way. (2) Use range(len(my_list)) and access my_list[i] manually. enumerate() is preferred because it is cleaner, avoids off-by-one errors, signals intent clearly, and works with any iterable — not just lists that have a len(). The range(len()) pattern works but is considered unidiomatic Python — it's the C or Java index loop imported into a language that doesn't need it.
  • QWhat happens if you modify a list while iterating over it with a for loop in Python? Why does this happen, and how do you fix it?Mid-levelReveal
    Python's for loop uses an internal iterator that tracks its position by index. When you remove an item, all subsequent items shift left by one position, but the iterator's index still advances. This causes the iterator to skip the item that moved into the position just processed. The result is silent data loss — no error, no warning. Fix: iterate over a copy (for item in my_list[:]) or use a list comprehension to build a filtered version: result = [x for x in my_list if not should_remove(x)]. The list comprehension is the preferred Pythonic approach.
  • QWhat is zip() and what happens when the two sequences you pass to it have different lengths?Mid-levelReveal
    zip() takes two or more iterables and pairs their items together, yielding one tuple per step. It is the Pythonic replacement for for i in range(len(a)): item_a = a[i]; item_b = b[i]. When the sequences have different lengths, zip() stops as soon as the shortest one is exhausted — items from the longer sequence are silently dropped. This is the correct behaviour when lengths are known to match, but a silent data loss bug when they might differ. Fix: use itertools.zip_longest(a, b, fillvalue=default) to process all items, or assert lengths match before zipping.
  • QWhat is the difference between a list comprehension and a generator expression, and when would you choose one over the other?SeniorReveal
    A list comprehension [expr for item in iterable] builds the entire result list in memory immediately. A generator expression (expr for item in iterable) is lazy — it produces one value at a time without storing the whole result. Use a list comprehension when you need random access, will iterate multiple times, or need len(). Use a generator expression when passing the result directly to sum(), max(), any(), all(), or similar functions that consume the sequence once — it uses constant memory regardless of input size. Example: sum(x2 for x in range(1_000_000)) uses ~100 bytes; sum([x2 for x in range(1_000_000)]) builds an 8MB list that is immediately discarded.

Frequently Asked Questions

Can I use a for loop to loop over a string in Python?

Yes, absolutely. A string in Python is a sequence of characters, so for char in 'hello' will visit 'h', 'e', 'l', 'l', 'o' one at a time. This is handy for character-level processing like counting vowels, checking if a string is a palindrome, or scanning for special characters. Strings implement the full iterator protocol — they work everywhere a list would work in a for loop.

What's the difference between for loop and while loop in Python?

Use a for loop when you know in advance what you're iterating over — a list, a range of numbers, a string, a file. Use a while loop when you want to keep repeating until some condition changes and you don't know how many iterations that will take — like reading user input until it's valid or polling a connection until data arrives. In practice, for loops cover the vast majority of everyday repetition tasks.

Why does Python use indentation to define the loop body instead of curly braces?

Python was designed to be readable — indentation makes the structure of the code visually obvious to humans without extra symbols. Everything indented at the same level below the for line is part of the loop body. The moment indentation returns to the previous level, you're outside the loop. This enforces clean, consistent formatting across all Python code and makes the visual structure match the logical structure.

What does `for _ in range(n)` mean — what is the underscore?

The underscore is a convention that signals 'I don't need this variable.' Python still creates it and increments it internally, but the underscore tells readers — and tools like linters and type checkers — that the loop variable is intentionally unused. Use it when you just need to repeat something N times and don't care about the counter value: for _ in range(5): print('hello').

How do I loop over two lists at the same time?

Use zip(). Write for item_a, item_b in zip(list_a, list_b) to pair items from both lists together, one pair per iteration. zip() is lazy, memory-efficient, and reads clearly. If your lists might have different lengths, use itertools.zip_longest(list_a, list_b, fillvalue=None) to avoid silently dropping items from the longer list.

What is the difference between a list comprehension and a for loop?

A list comprehension is a for loop that produces a new list, written in a single expression: [x 2 for x in numbers if x > 0]. A regular for loop is more flexible — it can do anything, including modifying external state, printing, writing to files, or updating databases. Use a list comprehension when the purpose is to transform or filter a collection into a new list. Use a regular for loop when the body has side effects or is too complex to fit cleanly on one line. If you're building a list and then passing it directly to sum() or similar, use a generator expression (x 2 for x in numbers) — it does the same thing with constant memory.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← Previousif-elif-else in PythonNext →while Loop in Python
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged