Python for Loop Explained — Syntax, Examples and Common Mistakes
- 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.
- 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 Incident
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.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.Production Debug GuideFrom silent skips to infinite hangs
for item in list[:] or a list comprehension insteaditertools.zip_longest() if you need to process all items from both sequencesEvery 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.
# ── 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)
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
- 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
iter() and next() is what separates developers who debug iterator errors from developers who are confused by them.iter() and next() makes debugging iterator errors straightforward.for item in my_list — direct iteration is cleanestfor _ in range(n) — underscore signals the variable is unusedfor i, item in enumerate(my_list) — not a manual counterfor key, value in my_dict.items() — direct dict iteration only gives keysLooping 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 . It generates a sequence of numbers on demand without storing them all in memory.range()
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: 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()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(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
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
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.range() directly for looping — never convert it to a list first unless you genuinely need random access or multiple passes.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: . 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()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 instead — it works with any iterable, handles the index arithmetic for you, and makes the code readable.enumerate()
# ── 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}")
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
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().enumerate() for indexed list access, dict.items() for key-value iteration. Both are O(1) overhead over direct iteration.len()) pattern — always prefer it.for item in my_list — simplest formfor i, item in enumerate(my_list) — never use range(len())for key in my_dict — direct iteration gives keys by defaultfor key, value in my_dict.items() — .items() returns (key, value) pairsLooping 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()
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 zip() from the standard library, which fills in a default value for the shorter sequence.itertools.zip_longest()
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.
also makes the code self-documenting. When someone reads zip()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.
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")
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
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.len()) when processing parallel lists.zip_longest() when lengths may differ.range().zip() — clean, lazy, and self-documentingzip_longest() from itertools with an explicit fillvalue — never silently lose dataControlling 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: 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}")
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)
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.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(), or max(), use a generator expression instead of a list comprehension — there's no reason to materialise the entire list just to discard it immediately.any()
# ── 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}")
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
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
sum([x for x in large_list if condition]) — building a full list in memory just to sum it and throw it away.sum(x for x in large_list if condition) is a generator expression — constant memory, same result.sum(), any(), all(), max(), or min(), drop the square brackets.sum(), any(), all(), max().sum(), max(), any(), all()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 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.'itertools.product()
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}")
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
- 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
set_b = set(list_b) before the loop — is one of the highest-impact single-line performance fixes in Python.itertools.product() for cartesian products instead of nested loops — same result, explicit intent.enumerate() both levels for clean index tracking| Aspect | for Loop | while Loop |
|---|---|---|
| Best use case | Known collection or fixed number of iterations | Repeat until a condition changes (unknown count) |
| Risk of infinite loop | Very low — stops when collection is exhausted | High — easy to forget updating the condition |
| Counting built-in | Yes, via range() or enumerate() | No — you manage your own counter |
| Readability | Very readable — intent is obvious | Can be harder to scan quickly |
| Looping over a list | Natural — for item in my_list | Possible but awkward with index management |
| Typical example | Process every order in a cart | Keep prompting user until valid input received |
| Memory efficiency | range() and zip() are lazy — constant memory | No built-in lazy generation |
| Parallel iteration | zip() pairs two sequences cleanly | Requires 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; useitertools.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
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
- 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
- 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
- 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
- QWhat is
zip()and what happens when the two sequences you pass to it have different lengths?Mid-levelReveal - QWhat is the difference between a list comprehension and a generator expression, and when would you choose one over the other?SeniorReveal
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 . Write zip()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.
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.