Python - Nested Loops

A nested loop is simply a loop inside another loop. The inner loop executes completely for each single iteration of the outer loop. This structure is fundamental when you need to work with...

Key Insights

  • Nested loops multiply iterations—an outer loop of 100 items with an inner loop of 100 items means 10,000 total iterations, making performance awareness critical
  • The break and continue statements only affect the innermost loop; escaping multiple levels requires flags, exceptions, or refactoring into functions
  • When you find yourself nesting beyond two or three levels, it’s usually a sign to refactor—extract inner loops into well-named functions or use built-in tools like itertools

Introduction to Nested Loops

A nested loop is simply a loop inside another loop. The inner loop executes completely for each single iteration of the outer loop. This structure is fundamental when you need to work with multi-dimensional data, generate combinations, or perform operations that require examining every pair of elements in a collection.

If you’ve ever worked with spreadsheets, you’ve intuitively understood nested loops: you might process each row (outer loop), and for each row, process each column (inner loop). The same logic applies in code.

Basic Syntax and Structure

Python’s indentation-based syntax makes nested loops visually clear. Each level of nesting requires an additional indent. Here’s the basic structure for nested for loops:

for outer_item in outer_sequence:
    # Outer loop body
    for inner_item in inner_sequence:
        # Inner loop body
        pass

The same pattern applies to while loops:

outer_counter = 0
while outer_counter < 3:
    inner_counter = 0
    while inner_counter < 3:
        print(f"outer: {outer_counter}, inner: {inner_counter}")
        inner_counter += 1
    outer_counter += 1

You can also mix loop types—a for loop containing a while loop or vice versa—depending on what makes sense for your use case.

Here’s a practical example that generates a multiplication table:

def print_multiplication_table(size):
    for row in range(1, size + 1):
        for col in range(1, size + 1):
            product = row * col
            print(f"{product:4}", end="")
        print()  # New line after each row

print_multiplication_table(5)

Output:

   1   2   3   4   5
   2   4   6   8  10
   3   6   9  12  15
   4   8  12  16  20
   5  10  15  20  25

How Nested Loops Execute

Understanding execution order is crucial for debugging and writing correct nested loops. The outer loop starts, then the inner loop runs to completion. Only after the inner loop finishes does the outer loop advance to its next iteration.

Let’s trace through execution explicitly:

for i in range(3):
    print(f"Outer loop: i = {i}")
    for j in range(4):
        print(f"    Inner loop: j = {j}")
    print(f"Inner loop complete for i = {i}")
    print()

Output:

Outer loop: i = 0
    Inner loop: j = 0
    Inner loop: j = 1
    Inner loop: j = 2
    Inner loop: j = 3
Inner loop complete for i = 0

Outer loop: i = 1
    Inner loop: j = 0
    Inner loop: j = 1
    Inner loop: j = 2
    Inner loop: j = 3
Inner loop complete for i = 1

Outer loop: i = 2
    Inner loop: j = 0
    Inner loop: j = 1
    Inner loop: j = 2
    Inner loop: j = 3
Inner loop complete for i = 2

Notice that j resets to 0 each time the outer loop advances. The inner loop is essentially “fresh” for each outer iteration. This is why nested loops with n outer iterations and m inner iterations result in n × m total inner loop executions.

Common Use Cases

Traversing 2D Data Structures

The most common use case is iterating over matrices or grids. When you have a list of lists, nested loops let you access every element:

def find_in_matrix(matrix, target):
    """Find target value in 2D matrix, return (row, col) or None."""
    for row_index, row in enumerate(matrix):
        for col_index, value in enumerate(row):
            if value == target:
                return (row_index, col_index)
    return None

matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

position = find_in_matrix(matrix, 5)
print(f"Found at position: {position}")  # Output: Found at position: (1, 1)

Generating Combinations

When you need to examine every pair of elements, nested loops are the natural choice:

def find_pairs_with_sum(numbers, target_sum):
    """Find all pairs of numbers that add up to target_sum."""
    pairs = []
    for i in range(len(numbers)):
        for j in range(i + 1, len(numbers)):  # Start at i+1 to avoid duplicates
            if numbers[i] + numbers[j] == target_sum:
                pairs.append((numbers[i], numbers[j]))
    return pairs

numbers = [1, 2, 3, 4, 5, 6]
print(find_pairs_with_sum(numbers, 7))  # Output: [(1, 6), (2, 5), (3, 4)]

Pattern Printing

Nested loops excel at generating text-based patterns:

def print_triangle(height):
    """Print a right triangle of asterisks."""
    for row in range(1, height + 1):
        for col in range(row):
            print("*", end="")
        print()

def print_pyramid(height):
    """Print a centered pyramid of asterisks."""
    for row in range(1, height + 1):
        # Print leading spaces
        for space in range(height - row):
            print(" ", end="")
        # Print asterisks
        for star in range(2 * row - 1):
            print("*", end="")
        print()

print("Triangle:")
print_triangle(5)
print("\nPyramid:")
print_pyramid(5)

Output:

Triangle:
*
**
***
****
*****

Pyramid:
    *
   ***
  *****
 *******
*********

Controlling Loop Flow with break and continue

Here’s where many developers get tripped up: break and continue only affect the loop they’re directly inside. They don’t magically escape multiple levels of nesting.

# This only breaks the inner loop
for i in range(3):
    for j in range(3):
        if j == 1:
            break  # Only exits the inner loop
        print(f"i={i}, j={j}")
    print(f"Finished inner loop for i={i}")

To break out of multiple loops, you have several options:

Option 1: Flag Variable

def search_with_flag(matrix, target):
    found = False
    for row_index, row in enumerate(matrix):
        for col_index, value in enumerate(row):
            if value == target:
                print(f"Found {target} at ({row_index}, {col_index})")
                found = True
                break
        if found:
            break

Option 2: Extract to Function (Preferred)

def search_matrix(matrix, target):
    """Return position of target or None. Using return exits all loops."""
    for row_index, row in enumerate(matrix):
        for col_index, value in enumerate(row):
            if value == target:
                return (row_index, col_index)
    return None

# Usage
matrix = [[1, 2], [3, 4], [5, 6]]
result = search_matrix(matrix, 4)
if result:
    print(f"Found at {result}")

The function approach is cleaner because return naturally exits all loops. It also makes your code more modular and testable.

Option 3: Exception (Use Sparingly)

class Found(Exception):
    pass

try:
    for i in range(10):
        for j in range(10):
            for k in range(10):
                if some_condition(i, j, k):
                    raise Found()
except Found:
    print("Exited all loops")

This works but feels like an abuse of exceptions. Reserve it for deeply nested loops where refactoring isn’t practical.

Performance Considerations

Nested loops have multiplicative complexity. A single loop over n items is O(n). Two nested loops over n items each is O(n²). Three levels gives you O(n³). This escalates quickly.

# O(n²) - 1000 items means 1,000,000 iterations
for i in range(1000):
    for j in range(1000):
        process(i, j)

Before writing nested loops, ask yourself if there’s a better approach.

Refactoring to List Comprehensions

List comprehensions can replace simple nested loops with more readable, often faster code:

# Nested loop approach
result = []
for i in range(5):
    for j in range(5):
        if i != j:
            result.append((i, j))

# List comprehension equivalent
result = [(i, j) for i in range(5) for j in range(5) if i != j]

Using itertools

The itertools module provides optimized functions for common iteration patterns:

from itertools import product, combinations

# Instead of nested loops for all pairs
for i in range(3):
    for j in range(3):
        print(i, j)

# Use itertools.product
for i, j in product(range(3), range(3)):
    print(i, j)

# For combinations without repetition
items = ['a', 'b', 'c', 'd']
for pair in combinations(items, 2):
    print(pair)  # ('a', 'b'), ('a', 'c'), etc.

Best Practices

Keep nesting shallow. Two levels of nesting is common and acceptable. Three levels should make you pause and consider refactoring. Four or more levels is almost always a code smell.

Use meaningful variable names. Avoid i, j, k when better names exist. Use row, col for matrices. Use student, course when iterating over students and their courses. Clear names make the code self-documenting.

# Hard to follow
for i in items:
    for j in i.sub_items:
        for k in j.details:
            process(k)

# Much clearer
for order in orders:
    for line_item in order.line_items:
        for discount in line_item.applicable_discounts:
            apply_discount(discount)

Extract inner loops to functions. When the inner loop has complex logic, pull it into a well-named function:

def process_order_items(order):
    """Handle all items in a single order."""
    for item in order.items:
        validate_item(item)
        calculate_tax(item)
        update_inventory(item)

# Main loop becomes simple
for order in pending_orders:
    process_order_items(order)

Consider generators for memory efficiency. When processing large datasets, generators prevent loading everything into memory:

def matrix_elements(matrix):
    """Yield each element with its position."""
    for i, row in enumerate(matrix):
        for j, value in enumerate(row):
            yield i, j, value

# Process elements one at a time
for row, col, value in matrix_elements(large_matrix):
    if meets_criteria(value):
        handle_element(row, col, value)

Nested loops are a fundamental tool, but like any tool, they require judgment about when and how to use them. Master the basics, understand the performance implications, and know when to reach for alternatives.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.