Foundations

Actuality

This article is still relevant because nested iteration appears everywhere: grouped query results, tree-like data, batched API responses, and objects with nested collections. The problem is not that nested loops are wrong. The problem is that, once business logic is added, they often make a function harder to scan.

The goal is not to eliminate every nested loop. The goal is to reduce indentation when the traversal itself is not the main point of the code.

Thesis

When the structure of iteration is simple but the data is nested, Python often reads better if traversal is flattened before the main logic is applied. In practice, the two most useful tools are itertools.chain.from_iterable(...) and generator expressions.

The gain is mostly local readability. You reduce visual nesting, make the main operation more visible, and move the traversal mechanics into one expression.

Core concepts

There are two separate operations in the examples below.

Flattening. Turning a nested iterable into a single stream of items.

Filtering. Keeping only the items that satisfy a condition.

Traversal logic. The structural part of the code that says where the data lives.

A useful refactoring is to separate traversal from the actual action you want to perform.

Limits

This pattern is useful, but it is not universally better.

  • If the flattening expression becomes too clever, readability gets worse rather than better.
  • If the nested loops contain several side effects, explicit loops may still be clearer.
  • Removing indentation does not reduce algorithmic complexity; it only changes presentation.

The right test is simple: if the refactored version is easier to understand on first reading, keep it. If not, keep the loops.

Implementation and examples

A simple nested collection

Suppose we have grouped houses and want to print every house from every inner list.

from dataclasses import dataclass


@dataclass
class House:
    address: str
    flats_number: int


houses_by_address = [
    [
        House(address="St 1", flats_number=90),
    ],
    [
        House(address="St 2", flats_number=40),
        House(address="St 2", flats_number=50),
    ],
]

for houses in houses_by_address:
    for house in houses:
        print(house)

This is correct, but the traversal is explicit at every level. If all we need is a flat stream of houses, chain.from_iterable(...) expresses that more directly.

from itertools import chain

for house in chain.from_iterable(houses_by_address):
    print(house)

# House(address='St 1', flats_number=90)
# House(address='St 2', flats_number=40)
# House(address='St 2', flats_number=50)

The point is not that two loops are bad. The point is that the flattened version says more clearly what we want: iterate over all houses.

When one level of flattening is not enough

The next case is more interesting. A house now contains flats, and we want to iterate through all flats across all houses.

chain.from_iterable(...) flattens one level. If the data remains nested after that, another step is still needed.

from dataclasses import dataclass
from itertools import chain


@dataclass
class Flat:
    number: int
    rooms: int


@dataclass
class House:
    address: str
    flats: list[Flat]


flat_1 = Flat(number=1, rooms=2)
flat_21 = Flat(number=21, rooms=3)
flat_12 = Flat(number=12, rooms=1)
flat_44 = Flat(number=44, rooms=1)
flat_18 = Flat(number=18, rooms=2)

houses_by_address = [
    [
        House(address="St 1", flats=[flat_1, flat_21]),
    ],
    [
        House(address="St 2", flats=[flat_12, flat_44]),
        House(address="St 3", flats=[flat_44, flat_18]),
    ],
]

A straightforward solution is still a nested loop:

for house in chain.from_iterable(houses_by_address):
    for flat in house.flats:
        if flat.rooms > 1:
            print(flat)

There is nothing wrong with this version. But if the function around it is already busy, a flat generator expression can make the main intent easier to see.

for flat in (
    flat
    for houses in houses_by_address
    for house in houses
    for flat in house.flats
    if flat.rooms > 1
):
    print(flat)

Or, if one level is already flattened with chain:

from itertools import chain

for flat in (
    flat
    for house in chain.from_iterable(houses_by_address)
    for flat in house.flats
    if flat.rooms > 1
):
    print(flat)

This version keeps the filtering condition close to the traversal and removes one indentation level from the outer block.

Wrapping the traversal in a function

The pattern becomes more useful when the traversal logic deserves a name.

Before:

from typing import Generator


def get_rooms_by_max(max_rooms: int) -> Generator[Flat, None, None]:
    for houses in houses_by_address:
        for house in houses:
            for flat in house.flats:
                if flat.rooms <= max_rooms:
                    yield flat

After:

from itertools import chain
from typing import Generator


def get_rooms_by_max(max_rooms: int) -> Generator[Flat, None, None]:
    for flat in (
        flat
        for house in chain.from_iterable(houses_by_address)
        for flat in house.flats
        if flat.rooms <= max_rooms
    ):
        yield flat

The refactored version is not shorter by much, but it is denser in a useful way: the traversal is described once, and the filtering condition stays inside the same expression.

In practice, I would go one step further and return the generator expression directly if the codebase allows it:

from collections.abc import Iterator
from itertools import chain


def get_rooms_by_max(max_rooms: int) -> Iterator[Flat]:
    return (
        flat
        for house in chain.from_iterable(houses_by_address)
        for flat in house.flats
        if flat.rooms <= max_rooms
    )

This version makes it especially obvious that the function is only a reusable traversal.

Summary

Nested loops are often fine. But when the nesting only reflects the data shape, not the core idea of the code, it is often worth flattening the iteration first.

chain.from_iterable(...) helps when you need to remove one structural level. Generator expressions help when you want the traversal and filtering to stay together without adding more indentation.

Recommendations

  • use explicit loops when the traversal itself is important to understanding the code
  • use chain.from_iterable(...) when you want to flatten one obvious level
  • use generator expressions when they make the main operation easier to scan
  • keep the expression readable; if it starts looking clever, stop
  • remember that flatter code is not automatically simpler code

Good refactoring here is not about saving lines. It is about making the real operation more visible than the mechanics of reaching the data.