How to Avoid Nested Loops in Python: Fix the Design, Not the Loop

Nested loops are rarely the real problem. Learn 3 design questions that expose what your Python loops are hiding, with before/after code and Big O advice.

Built With: Python

Looking for the basic syntax? In Python, a nested loop is simply a loop inside another:

for outer in [1, 2]:
    for inner in ["a", "b"]:
        print(outer, inner)  # Prints: 1 a, 1 b, 2 a, 2 b

If you already know the basics and your real problem is that your code has grown into an unreadable, deeply nested mess that your team hates touching, you are in the right place.

A developer on Reddit asked a question that thousands of Python learners have typed in some form. A linter had flagged their code with too-many-nested-blocks, and they wrote that they had no idea how to fix nested loops, or how to learn to fix them. The warning named the symptom. Nothing in the toolchain explained the cure.

That gap is what this guide fills.

Here is the situation. You have a function with a loop inside a loop inside an if. It runs. The numbers come out right. And nobody on your team wants to touch it, including you.

The instinct is to attack the loops: make them smarter, flatter, faster. That instinct is usually wrong. Nested loops are rarely the root of the problem. They are the visible surface of a design problem underneath, and once you find that problem, the loops often fix themselves.

This guide approaches the problem through foundational software design principles rather than surface-level syntax tricks. We build a realistic e-commerce reporting scenario from scratch, back every refactoring step with empirical benchmarks, test it against real-world engineering debates, and outline the exact cases where a nested loop remains the right tool.

Key takeaways

Click any topic to expand or collapse
A nested loop is a signal, not the enemy

It tells you to look at the design around it.

Ask three questions in order

Is the data structure right? Is the behavior at the right level? Does this function do too much?

The Manual Join Smell

If the inner loop scans a whole collection to find matches for one outer item, group the data once with a dictionary.

Nesting that mirrors your data is fine

Grids, pairs, and lists of lists are natural. Mixed concerns and unrelated collections are the smell.

O(nΒ²) only hurts at scale

Measure before you rewrite, but fix the structure when the sizes will grow.

After the refactor, loops may remain

What changes is that every function has one reason to change.

What is a nested loop?

A nested loop is a loop inside another loop. For every single pass of the outer loop, the inner loop runs from start to finish. Ten outer passes over ten inner items therefore produce 100 inner steps, which is why nesting multiplies work instead of adding it.

Python:

for row in range(3): for col in range(3): print(row, col) # runs 3 x 3 = 9 times

That is the textbook version, and there is nothing wrong with it. The trouble starts when real code grows around the same shape.

Nested loops code smell
Nested loops code smell

What is a code smell?

A code smell is a surface sign in source code that often points to a deeper design problem. It isn’t a bug, and the code may run fine. The term comes from the refactoring tradition and tells you where to look, not what to change.

Martin Fowler’s catalog of refactorings is the classic reference for the idea. Think of a smoke detector. It can go off because of a fire, or because of burnt toast. Either way, you walk to the kitchen and look.

Nested loops belong in that category. They are a prompt to investigate. They are not a verdict.

Are nested loops bad practice?

No, not by themselves. Nested loops are a problem when they hide a design flaw: they join unrelated collections, mix several concerns in one block, or force the reader to hold too many levels in their head. When the nesting mirrors the shape of the data, such as a grid, it is the natural tool.

You don’t have to take that on faith. The community has been circling this answer for years.

On the Software Engineering Stack Exchange, the most-upvoted answer to “Why are nested loops considered bad practice?” says nested loops are fine as long as they describe the correct algorithm, and that long ones are usually better split into smaller functions. A second answer in the same thread works through a concrete case. Two lists of 100 items, with one match each, cost about 5,000 comparisons on average with a nested scan, while a hash-based approach does the job far faster.

On r/learnprogramming, one commenter said two loops over 2D data, or three over 3D data, is totally natural. What should be avoided, they added, is nesting loops over unrelated data. Another thread landed in the same place: nested loops get bad when they mix layers of logic or get tangled with each other. Treat these as individual experiences rather than law. But several separate people reaching the same rule is worth noticing.

So why do nested loops have such a bad reputation? Three reasons keep coming up.

Nested loops in code
Nested loops in code

Readability: every level adds context

Each level of nesting adds one more thing the reader must remember while reading the line they are on. This is not just a feeling. G. Ann Campbell’s Cognitive Complexity white paper from SonarSource (version 1.7, 2023) describes a metric built to measure how hard code is to understand. Two of its rules matter here. Every loop or conditional adds one point, and each time one sits inside another, it adds an extra point per level of nesting. The paper’s reasoning is that a linear series of five if and for structures is easier to follow than the same five nested.

The metric also gives method structure no penalty at all, because a well-named method condenses several statements into one call. Keep that in mind, because it is exactly what we do in Question 3.

That is different from the older cyclomatic complexity measure McCabe introduced in 1976, which counts independent paths through code. Cyclomatic complexity asks how many paths exist. Cognitive complexity asks how hard those paths are for a person to follow.

Linters: a number on a dashboard

Pylint’s too-many-nested-blocks check (message R1702) fires when a function nests more blocks than its limit. The Pylint documentation describes it, and the default in Pylint 4.1.3, which generated the config file used for this guide, is max-nested-blocks=5. The check counts blocks, not just loops: in the example later in this article, if statements count toward the total alongside for loops.

Here is the uncomfortable part, and it is the first uncommon insight in this guide: your linter is right for the wrong reason. It flags depth, which is a proxy. The real reason to care is that depth compounds mental effort and tends to hide design problems. If you silence the warning, you hide the symptom. If you flatten the code mechanically, you may produce something just as hard to read.

The linter story often starts in interview prep. The Reddit poster above was working through a coding assignment. If you are in that spot, our list of data engineering interview questions worth practicing shows how often this kind of code shows up in screens.

Performance: work multiplies

Two loops over lists of size n and m can do n Γ— m units of work. We will put numbers on that later, because the honest answer is more interesting than “nested loops are slow.”

Note: Depth is not the metric. Meaning per level is. A two-level loop where each level has a clear job reads better than a flat function that does six unrelated things.

Five terms people mix up

Search results for this topic blur several different ideas together. This table separates them.

TermWhat it meansSource or status
Nested loopA loop inside another loop. The inner loop runs fully for each pass of the outer one.Basic definition
Nested blocks (Pylint)A count of nested blocks in one function, with a default limit of 5. It includes if blocks, not only loops.Pylint docs and a local test on Pylint 4.1.3
Cyclomatic complexityA count of independent paths through code, introduced by McCabe in 1976.McCabe, IEEE TSE
Cognitive ComplexityA score for how hard code is to understand. It adds points for breaks in linear flow and for nesting.Sonar white paper v1.7
Manual Join SmellThis guide’s name for scanning one collection for every item in another to match keys.Editorial term, not an industry standard

Nested loops earn their bad reputation from readability, linter rules, and performance. None of the three is a problem with loops themselves. All three are problems with what the loops contain.

Why readability is a business problem, not a style preference

It is easy to dismiss all of this as taste. The industry data says otherwise.

Stripe’s 2018 Developer Coefficient report, based on a survey run with Harris Poll, asked respondents to estimate how developers spend their time. The average estimate was 17.3 hours of a 41.1-hour week on maintenance work such as debugging, refactoring, and dealing with bad code: 13.5 hours on technical debt plus 3.8 hours on bad code. By simple division, that is about 42% of the week. These are survey estimates, not time-tracking data, and the report is from 2018.

The Stack Overflow 2024 Developer Survey asked professional developers which company challenges frustrate them most (select all that apply). Technical debt came first, chosen by 62.4% of respondents. The same survey section also found that improving the quality of code and developer environments contributed the most to job satisfaction, at a mean of about 21 out of 100 points.

A July 2020 survey by McKinsey of 50 CIOs at financial-services and technology companies with more than $1 billion in revenue found that CIOs reported 10 to 20 percent of the technology budget for new products being diverted to tech debt. The same article is clear that the goal is not zero tech debt, only managed tech debt. That is the same attitude this guide takes toward loops: the goal is not zero, it is understood.

17.3 hrs
of a 41.1-hour week, as estimated by survey respondents, go to technical debt and bad code (2018)
Stripe and Harris Poll, Developer Coefficient
62.4%
of professional developers picked technical debt as a top frustration (select all that apply)
Stack Overflow Developer Survey 2024
10–20%
of new-product technology budgets diverted to tech debt, per 50 surveyed CIOs (2020)
McKinsey, Tech debt research

One honest limit. None of these studies measured nested loops. A single nested loop is not a tech-debt crisis. But debt rarely arrives in one piece. It arrives as one more condition, one more loop, one more special case, and readable structure is the cheapest defense against that slow growth.

Hard-to-read code is not an aesthetic problem. Developers and executives both report maintenance burden as a real cost, and developers say better code quality is what satisfies them most.

The code that works and nobody wants to touch

Let’s make this concrete with a realistic example. A shop wants a report for each customer: how many paid orders they placed and how much they spent, with a 5% discount applied for VIP customers.

What you need to follow along

The code in this guide runs on Python 3.9 or newer. The reason is one line: items: list[OrderItem] uses the built-in generic syntax from PEP 585, which targets Python 3.9. The start= keyword argument of sum() needs 3.8 or newer, according to the Python documentation for sum. Nothing outside the standard library is required.

ItemRequirement or setupEvidence
Python3.9 or newerPEP 585 lists Python 3.9 as its target version
sum(..., start=...)3.8 or newerPython docs: “Changed in version 3.8”
LibrariesStandard library onlyChecked by inspection of the code
Everything in this guide was run onCPython 3.12.3, LinuxHands-on observation
Measurement toolsPylint 4.1.3, cognitive_complexity 1.3.0Hands-on observation

Other Python versions and operating systems were not tested here, so treat the results as observations from that one setup.

First, the data models. These stay the same throughout the refactor, apart from one property we will add later.

Python: data models:

from dataclasses import dataclass from decimal import Decimal from enum import Enum
class OrderStatus(Enum): PAID = "paid" PENDING = "pending" CANCELLED = "cancelled"
@dataclass(frozen=True) class OrderItem: price: Decimal quantity: int
@dataclass class Order: order_id: int customer_id: int status: OrderStatus items: list[OrderItem]
@dataclass(frozen=True) class Customer: customer_id: int name: str is_vip: bool = False
@dataclass(frozen=True) class CustomerSummary: customer_id: int name: str number_of_orders: int total_spent: Decimal

Now the report function, written the way code tends to get written when requirements arrive one at a time.

Python: BEFORE (works, but hard to read):

def generate_customer_report(customers, orders): report = {} for customer in customers: total_spent = Decimal("0") number_of_orders = 0 for order in orders: if order.customer_id == customer.customer_id: if order.status == OrderStatus.PAID: order_total = Decimal("0") for item in order.items: order_total += item.price * item.quantity if customer.is_vip: order_total = order_total * Decimal("0.95") total_spent += order_total number_of_orders += 1 report[customer.customer_id] = CustomerSummary( customer_id=customer.customer_id, name=customer.name, number_of_orders=number_of_orders, total_spent=total_spent, ) return report

Run it with a VIP customer named Alice who has one paid order of $50 (two items at $20 and one at $10) plus one pending order, a regular customer named Bob with one paid and one cancelled order, and a customer with no orders. The result is correct: Alice’s total is 47.50 after the discount, Bob’s is 31.00, and the third customer shows zero. One display detail: Python prints Alice’s total as Decimal('47.5000'), because multiplying by Decimal("0.95") keeps the extra digits. The value is right. Format it with f"{total:.2f}" when you show it to a person.

So the code works. It is still painful to read, and it is worth asking why.

Refactoring complex Python customer
Refactoring complex Python customer

The function is five blocks deep: for, for, if, if, for. Running Pylint’s nested-blocks check on it with the default limit of five produces no warning at all. Lower the limit to four and it fires. In other words, the linter passes this function while a human would struggle with it. That is a good reminder that the metric and the problem are different things.

You can put a number on how hard that function is to follow. Applying the white paper’s rules by hand gives a Cognitive Complexity of 20, and the open-source cognitive_complexity package (version 1.3.0) reports the same 20. Here is where the points come from.

Line in the functionStructureNesting levelPoints
for customer in customersfor0+1
for order in ordersfor1+2
if order.customer_id == customer.customer_idif2+3
if order.status == OrderStatus.PAIDif3+4
for item in order.itemsfor4+5
if customer.is_vipif4+5
Total20

Treat this as an illustration of how the metric works. It is not an official SonarQube score, and other analyzers may count Python constructs such as comprehensions differently.

Count what the function actually does in its body:

  • Finds the orders belonging to one customer.
  • Filters out the ones that aren’t paid.
  • Adds up the price of each item.
  • Applies the VIP discount.
  • Counts the orders.
  • Builds the summary object.

Six jobs, one function, five levels of indentation. Now the tempting move is to rewrite the algorithm into something cleverer. Don’t. The algorithm is not the issue.

The 3 Design Questions: a diagnostic you can reuse

When you meet a tangled loop, don’t ask “how do I get rid of this?” Ask three questions, in this order. The order matters. Fixing the data structure removes a whole loop. Fixing the behavior level removes noise. Fixing responsibilities names whatever remains.

The 3 Design Questions
1
Is the data structure right?
Smell: the inner loop scans a whole collection for every outer item.
Fix: group or index the data once (a dictionary), or let the database join.
↓
2
Is the behavior at the right level?
Smell: low-level work (like an order total) sits inside high-level code (like a report).
Fix: move behavior onto the object whose data it uses.
↓
3
Does this function have too many reasons to change?
Smell: a discount change, a status change, and a format change all edit the same function.
Fix: split into named functions with one reason to change each.

We will walk through each one on the report code above.

Question 1: Are you using the right data structure?

Look at the first two lines of the loop body: for customer in customers and, inside it, for order in orders. For every customer, the code scans every order, then throws away the ones that don’t match.

That has a name. I’ll call it the Manual Join Smell: any for a in As: for b in Bs: if b.key == a.key pattern is an in-memory database join, done the slow way.

If this data lived in a database, you would write a JOIN and the engine would use an index. In memory, you re-implemented the join with no index, so the work is the product of the two sizes. The fix is not a smarter loop. It is better data modeling. If your data really does live in a database, let your database do the join instead. That is where joins belong.

When the data is already in memory, build the index yourself, once.

Optimizing Python loops and joins
Optimizing Python loops and joins

How to group a list by key in Python

  1. Create a defaultdict(list) that maps each key to a list.
  2. Loop over the items once and append each item under its key. Then look up groups with .get(key, []).

Python: the join, done once:

from collections import defaultdict
def group_orders_by_customer(orders): orders_by_customer = defaultdict(list) for order in orders: orders_by_customer[order.customer_id].append(order) return orders_by_customer
Inside the report function:
orders_by_customer = group_orders_by_customer(orders) customer_orders = orders_by_customer.get(customer.customer_id, [])

While you could build a similar index using a standard dictionary’s .setdefault() method, collections.defaultdict is the more idiomatic tool for the job because it creates the empty list automatically the first time a key appears.

Warning: Reading a missing key with square brackets on a defaultdict inserts that key. Use .get(key, []) when you only want to look something up, so a customer with no orders doesn’t quietly grow your dictionary. A quick check on Python 3.12.3 confirmed both behaviors: indexing a missing key adds it, and `.get()` does not.

Why does this help? The Python wiki’s time complexity page lists dictionary lookup as O(1) on average and x in list as O(n). That page describes current CPython, assumes keys with well-behaved hash functions, and is now marked as archived, so treat it as a reference for typical behavior, not a guarantee. A dictionary replaces “search everything” with “go straight to it.” The trade-off is memory: you now hold an index alongside the data.

The two approaches grow very differently, and the formula is short enough to hold in your head.

The formula
Nested scan: comparisons = n × m
Group first: operations ≈ n + m
n = outer items (customers), m = inner items scanned (orders)

Try your own numbers. The speed field is a placeholder, so replace it with a rate you have measured on your machine using Python’s timeit module.

Interactive calculator: nested scan vs. group first

Illustrative arithmetic only. Real timings depend on your hardware, data, and what each comparison does.

Notice what happens with small inputs: nothing. With 10 customers and 10 orders, the nested version does 100 comparisons and you will never feel it. That is exactly why this smell survives. It hides in development and shows itself in production.

Case study 1: how a linear scan kept GTA Online loading for six minutes

Case study: GTA Online load times

Challenge. A player known as t0st got tired of waiting about six minutes for GTA Online to load. In a write-up of the investigation, he found the game maxing out a single CPU core while it processed a large JSON file listing in-game items.

Action. He found two problems. One was a slow JSON parser. The other was a duplicate check: after each item was parsed, the code compared its hash against an entire array, one entry at a time, instead of using a hash map. Since the hashes were already unique, his fix skipped the duplicate checks entirely.

Outcome. On his machine, load time dropped from about six minutes to about 1 minute 50 seconds, as Simon Willison’s summary notes. Rockstar later confirmed the issue and shipped changes in a title update. Part of the saving came from each of the two fixes, so don’t credit the whole drop to the array scan.

Lesson. This is the Manual Join Smell at production scale: repeated scanning where an index belonged. It survived for years because every individual item looked fast. The cost only appeared when the list got long.

If your loops are really data pipeline code, the same question applies at a bigger scale. Libraries like pandas, Polars, and DuckDB exist to run joins and group-bys in optimized code. Our guide on picking the right Python data library for the job walks through where each one fits.

The first move is almost never a cleverer loop. It is building an index so the loop doesn’t have to search.

Question 2: Is the behavior at the right level?

With the grouping fix applied, one nested loop is gone and the function is already simpler. But look at this part of the body:

Python: low-level work inside high-level code:

order_total = Decimal("0") for item in order.items: order_total += item.price * item.quantity

This is about the level of the code, not about responsibilities in the abstract. The surrounding function is high-level: it builds a customer report. The three lines above compute what an order costs, which is a low-level fact about an order. It doesn’t belong in report code. It belongs to the Order.

A simple test sorts these cases. If a behavior only needs data from the object itself, it probably belongs on that object. An order total needs only item prices and quantities, and both live on the order. So move it.

Python: behavior moves onto Order:

@dataclass class Order: order_id: int customer_id: int status: OrderStatus items: list[OrderItem]
@property
def total(self):
    return sum(
        (item.price * item.quantity for item in self.items),
        start=Decimal("0"),
    )

The start=Decimal("0") matters. sum() accepts a start value, and passing an explicit Decimal zero keeps the result in exact decimal arithmetic, which is what you want for money. Now the report code just reads order.total, and the inner item loop disappears from it.

Moving behavior to object
Moving behavior to object

The discount is a different case. apply_discount needs the customer and a total, so it doesn’t belong on Order. That is the test working as intended: it tells you what to move, and what to leave.

This idea has an older name in object-oriented design. Tell, Don’t Ask says to tell an object to do work with its own data instead of pulling the data out and doing the work somewhere else.

Move behavior to the object that owns the data it needs. The loop gets simpler because it stops doing someone else’s job.

Question 3: Does this function have too many reasons to change?

Even after two fixes, the function still finds orders, filters paid ones, applies discounts, and builds output. That is a lot for one function, but “too many responsibilities” is a fuzzy phrase. Developers argue about it constantly.

In a Reddit thread titled I’ve Vastly Misunderstood the Single Responsibility Principle, which drew well over a hundred comments, one commenter’s complaint got a lot of agreement: what counts as a responsibility is never defined. Robert C. Martin addressed that confusion himself in a 2014 post on the Clean Code Blog. He ties “reason to change” to people: changes to a module should come from a single person, or a single tightly coupled group representing one narrow business function. Wikipedia’s summary shows how the wording later became “responsible to one actor.”

You can turn that debate into something you can do in five minutes. I’ll call it the Reasons-to-Change Audit.

Reasons to change audit function
Reasons to change audit function

The Reasons-to-Change Audit

List the realistic change requests for the function. For each one, ask whether you would have to edit this function. If three unrelated requests all land on the same function, it has three responsibilities.

Change requestBeforeAfter
The VIP discount rule changesEdit the report functionapply_discount
Order status is stored somewhere elseEdit the report functionpaid_orders
The summary gets a new fieldEdit the report functionbuild_customer_summary
Order totals need tax or bundlesEdit the report functionOrder.total
Orders are fetched or grouped differentlyEdit the report functiongroup_orders_by_customer

Five unrelated change requests, one function to edit. That is the real definition of “too much,” and it is the problem the nested loops were advertising.

Two other rules of thumb came up in that same Reddit thread, and they are worth keeping as quick checks. One commenter suggested that a class with many private methods can probably be split. Another suggested that a Boolean flag parameter that switches between two behaviors might really be two things. These are opinions from practitioners, not standards, but they are cheap to apply.

Now split the function along those lines.

Python: three extractions:

def paid_orders(orders): return [order for order in orders if order.status == OrderStatus.PAID]
def apply_discount(customer, total): if customer.is_vip: return total * Decimal("0.95") # 5% VIP discount return total
def build_customer_summary(customer, orders): totals = [ apply_discount(customer, order.total) for order in paid_orders(orders) ] return CustomerSummary( customer_id=customer.customer_id, name=customer.name, number_of_orders=len(totals), total_spent=sum(totals, start=Decimal("0")), )
Tip: Good extractions work like comments that can’t go stale. paid_orders tells the reader what is happening before they read a single line of its body. Most editors can do this for you, and our explainer on what an IDE does for you covers the refactoring tools behind Extract Function.

You don’t always have to go all the way. Splitting out even a little reduces the load.

Count reasons to change. If one function would be edited for several unrelated requests, split it until each piece answers to one.

The before and after: what actually changed

Here is the report function after all three fixes.

Python: AFTER (short orchestration function):

def generate_customer_report(customers, orders): orders_by_customer = group_orders_by_customer(orders) report = {} for customer in customers: customer_orders = orders_by_customer.get(customer.customer_id, []) report[customer.customer_id] = build_customer_summary(customer, customer_orders) return report

It reads like a sentence: group the orders, then for each customer, build a summary. I ran both versions on the sample data above, and they produce identical output.

What we measuredBeforeAfter
Deepest block nesting in one function5 blocks1 block (a single for)
Length of the main function22 lines7 lines
Reasons to change the main function5 (see the audit above)1 (how the report is assembled)
Orders scanned per customerAll of themOnly that customer’s
Loops still in the codeYesYes, inside comprehensions, sum(), and small functions
Cognitive Complexity (cognitive_complexity 1.3.0)20 for the one function0 or 1 for each of the six functions and methods

That last row matters. We did not eliminate the loops, and we weren’t trying to. The items still get iterated, and the orders still get iterated. The difference is that each loop now lives in a function with one clear job and a name that says what it is. Fix the design, and the loops mostly fix themselves, even if some of them stay.

How to refactor nested loops in Python in 3 steps

  1. Fix the data structure. If an inner loop searches a collection for matches, build a dictionary once with defaultdict(list) and look items up by key.
  2. Move low-level behavior to the object. If code needs only an object’s own data, make it a method or property on that object.
  3. Split by reasons to change. Extract named functions (filter, calculate, build) until each has one reason to change.
Show the complete refactored file (copy and run)
from collections import defaultdict from dataclasses import dataclass from decimal import Decimal from enum import Enum # ---------- models ---------- class OrderStatus(Enum): PAID = "paid" PENDING = "pending" CANCELLED = "cancelled" @dataclass(frozen=True) class OrderItem: price: Decimal quantity: int @dataclass class Order: order_id: int customer_id: int status: OrderStatus items: list[OrderItem] @property def total(self): return sum( (item.price * item.quantity for item in self.items), start=Decimal("0"), ) @dataclass(frozen=True) class Customer: customer_id: int name: str is_vip: bool = False @dataclass(frozen=True) class CustomerSummary: customer_id: int name: str number_of_orders: int total_spent: Decimal # ---------- behavior ---------- def group_orders_by_customer(orders): orders_by_customer = defaultdict(list) for order in orders: orders_by_customer[order.customer_id].append(order) return orders_by_customer def paid_orders(orders): return [order for order in orders if order.status == OrderStatus.PAID] def apply_discount(customer, total): if customer.is_vip: return total * Decimal("0.95") return total def build_customer_summary(customer, orders): totals = [ apply_discount(customer, order.total) for order in paid_orders(orders) ] return CustomerSummary( customer_id=customer.customer_id, name=customer.name, number_of_orders=len(totals), total_spent=sum(totals, start=Decimal("0")), ) def generate_customer_report(customers, orders): orders_by_customer = group_orders_by_customer(orders) report = {} for customer in customers: customer_orders = orders_by_customer.get(customer.customer_id, []) report[customer.customer_id] = build_customer_summary(customer, customer_orders) return report # ---------- try it ---------- customers = [Customer(1, "Alice", is_vip=True), Customer(2, "Bob"), Customer(3, "Cara")] orders = [ Order(101, 1, OrderStatus.PAID, [OrderItem(Decimal("20.00"), 2), OrderItem(Decimal("10.00"), 1)]), Order(102, 2, OrderStatus.PAID, [OrderItem(Decimal("15.50"), 2)]), Order(103, 2, OrderStatus.CANCELLED, [OrderItem(Decimal("99.00"), 1)]), Order(104, 1, OrderStatus.PENDING, [OrderItem(Decimal("5.00"), 4)]), ] for summary in generate_customer_report(customers, orders).values(): print(summary)

Expected output

Running the complete file prints one line per customer. The long Decimal('47.5000') for Alice is expected, as explained earlier.

Output (CPython 3.12.3):

CustomerSummary(customer_id=1, name='Alice', number_of_orders=1, total_spent=Decimal('47.5000')) CustomerSummary(customer_id=2, name='Bob', number_of_orders=1, total_spent=Decimal('31.00')) CustomerSummary(customer_id=3, name='Cara', number_of_orders=0, total_spent=Decimal('0'))

How to verify the refactor kept the behavior

A refactor is only safe if you can show nothing changed. Commit the working BEFORE version first, so rolling back is a single git revert. Then run both versions on the same seeded data and compare. Two small checks cover the main risks.

Python: a safety net you can run with pytest:

import random from decimal import Decimal
#Keep the old function next to the new one while you refactor.
#generate_customer_report_before is the BEFORE version from earlier.
def make_data(n_customers=300, orders_per_customer=4, seed=7): rnd = random.Random(seed) customers = [Customer(i, f"c{i}", is_vip=(i % 5 == 0)) for i in range(n_customers)] orders = [] for order_id in range(n_customers * orders_per_customer): items = [OrderItem(Decimal(rnd.randint(100, 5000)) / 100, rnd.randint(1, 3)) for _ in range(rnd.randint(0, 3))] # customer ids go slightly past the real range, so some orders are orphans orders.append(Order(order_id, rnd.randrange(n_customers + 20), rnd.choice(list(OrderStatus)), items)) return customers, orders
def test_refactor_keeps_behavior(): customers, orders = make_data() assert generate_customer_report_before(customers, orders) == generate_customer_report(customers, orders)
def test_grouping_loses_no_orders(): _, orders = make_data() groups = group_orders_by_customer(orders) assert sum(len(v) for v in groups.values()) == len(orders)
Note: Both checks passed on this seeded data in Python 3.12.3. They show that the two versions agree on the data tested, not that they agree on every possible input. Add cases that match your real data, such as customers with no orders or orders with no items.

What can go wrong with the grouped version

The dictionary fixes the scan, and it brings its own failure modes. Most of them are silent, which is why they are worth knowing before they happen.

Failure modeHow it shows upHow to catch itEvidence
Key type mismatch, such as "1" versus 1The lookup returns an empty list, so a customer silently shows zero orders.Normalize ID types when loading data, and test with real IDs.Observed locally: .get('1', []) returned an empty list while .get(1, []) found the order
Reading a missing key with square bracketsThe defaultdict quietly inserts an empty list.Use .get(key, []) for lookups.Observed locally on Python 3.12.3
Stale indexIf the orders list changes after grouping, the report uses old data.Build the index inside the report call, or rebuild it after changes.By design, follows from how an index works
Orders for unknown customersThey are never reported, in the old version or the new one.Compare group sizes to the input length, as in the second check above.Included as orphan orders in the seeded test data
Extra memoryThe index holds a reference to every order.Fine for in-memory sizes. For very large data, let the database do the join.By construction. Memory use was not measured here

How many nested loops is too many?

There is no magic number. Two levels over data that is genuinely two-dimensional is normal. Three or more levels, or any level that scans an unrelated collection, deserves a design check. Pylint’s default alarm sits at five nested blocks, but meaning per level matters more than the count.

A useful rule from the community fits this well. In an r/learnjavascript discussion, one developer described not worrying about a nested if, then re-examining their approach when they reach a third level. That is a personal heuristic, not a standard, but it matches how the tools work: Cognitive Complexity adds a growing penalty for each extra level.

Use this table as a quick triage.

What you seeVerdictWhat to do
Two loops over a grid or matrixFineName variables row and col. Leave it.
All pairs or combinations of itemsFine, watch sizeConsider itertools.product or combinations to flatten the nesting.
Inner loop searches another collection for a matching keyManual Join SmellGroup or index first with a dictionary.
Each level does different work (filter, compute, format)Responsibility smellExtract named functions.
Inner loop changes the outer loop’s index or stateReadability trapRestructure so each loop owns its own variables.
You need to exit both loops at onceExtractMove the loops into a function and return.

When nested loops are exactly the right tool

This is the contrarian half of the argument, and it is worth stating plainly: the nested loop isn’t the enemy. If you are walking a grid, comparing every pair of items, or reading a list of lists, nesting that mirrors the data is the clearest code you can write.

For flat pairings, the standard library gives you a way to keep the intent while removing the indentation. itertools.product yields every combination of its inputs from a single loop.

Even database engines use nested loops on purpose. The next case study shows how.

Case study 2: why Oracle’s optimizer picks nested loops on purpose

Case study: nested loops inside a database engine

Challenge. A database has to join two tables, and there are several join algorithms to choose from. Which one is right depends on how much data is involved and how the inner table can be accessed.

Action. According to the 12.2 edition of Oracle’s SQL Tuning Guide, a nested loops join is conceptually equivalent to two nested for loops: for each row in the outer data set, the database retrieves the matching rows of the inner one. The optimizer considers it when the join covers small subsets of data, or when the join condition is an efficient way to reach the inner table, and it works best with indexes on the join columns.

Outcome. Oracle’s documentation describes the conditions rather than publishing a before-and-after benchmark, so there is no performance figure to quote here. Evidence for a measured gain is limited in the sources reviewed.

Lesson. The loop shape is the same as the one in our “before” code. What differs is the inner access: an index lookup, not a full scan. That is Question 1 again. A nested loop with an index is fine. A nested loop that rescans a list is the smell.

Ask what the nesting mirrors. If it mirrors the data, keep it. If it mirrors a join you forgot to index, fix the structure.

The performance truth: when O(nΒ²) actually hurts

Big O describes how work grows as inputs grow. It doesn’t tell you how many seconds anything takes. Developers on both sides of this argument tend to forget that. Tutorials often ignore performance. Q&A threads sometimes overstate it. The honest answer sits in between, and it depends on size.

The arithmetic is simple. When both collections have the same size n, a nested scan does n Γ— n comparisons, and grouping first does about 2n operations.

Items in each list (n)Nested scan (n × n)Group first (≈ 2n)Difference
10010,00020050×
1,0001,000,0002,000500×
10,000100,000,00020,0005,000×
100,00010,000,000,000200,00050,000×

Read the table as a growth pattern, not a stopwatch. At 100 items the gap is irrelevant. At 10,000 it is the difference between a quick script and a job you start and walk away from. Use the calculator above to try your own sizes.

What it looks like on real code: one measurement

The table above is arithmetic. To see the same pattern on the report function from this guide, the BEFORE and AFTER versions were timed on seeded random data. The results below come from one run on one machine, so read them as an illustration of the growth pattern, not as a benchmark.

CustomersOrdersBEFORE (seconds)AFTER (seconds)Ratio
5002,5000.0510.003215.8×
1,0005,0000.1820.008122.5×
2,00010,0000.6710.016540.6×
4,00020,0002.8800.039273.4×

Each row doubles both the customers and the orders, so the nested scan’s work (n Γ— m) quadruples while the grouped version’s work (n + m) doubles. The measured times followed that shape: the BEFORE time grew by roughly 3.6 to 4.3 times per row, and the AFTER time by roughly 2.0 to 2.5 times. The ratio between them widened from about 16 times to about 73 times.

Warning: Conditions and limits. CPython 3.12.3 on a Linux virtual machine with an Intel Xeon 2.80 GHz processor. Seeded data (seed 42), 5 orders per customer, 1 to 3 items per order, one in five customers VIP, statuses chosen at random. Each time is the best of 3 runs, except the largest BEFORE run, which was run once. Caches and background load were not controlled. This does not show how your data shape or hardware will behave, so run the protocol below on your own inputs.
Show the benchmark protocol (copy and run)
import random, timeit from decimal import Decimal
def make(n_customers, orders_per_customer, seed=42): rnd = random.Random(seed) customers = [Customer(i, f"c{i}", is_vip=(i % 5 == 0)) for i in range(n_customers)] orders = [] for order_id in range(n_customers * orders_per_customer): items = [OrderItem(Decimal(rnd.randint(100, 5000)) / 100, rnd.randint(1, 3)) for _ in range(rnd.randint(1, 3))] orders.append(Order(order_id, rnd.randrange(n_customers), rnd.choice(list(OrderStatus)), items)) return customers, orders
for n in (500, 1000, 2000, 4000): customers, orders = make(n, 5) assert generate_customer_report_before(customers, orders) == generate_customer_report(customers, orders) t_before = min(timeit.repeat(lambda: generate_customer_report_before(customers, orders), number=1, repeat=3 if n < 4000 else 1)) t_after = min(timeit.repeat(lambda: generate_customer_report(customers, orders), number=1, repeat=3)) print(n, n * 5, round(t_before, 3), round(t_after, 4))

So when should you act?

  • Fix the structure now when the collections will grow, when the code runs on every request, or when it already feels slow.
  • Leave it alone when the lists are small and bounded, or the code runs once. The design reasons from earlier may still apply, but the performance reason doesn't.
  • Measure first when you are not sure. Guessing which loop is slow is how people optimize the wrong thing, and our piece on how to profile before you optimize shows the habit applied to Python.

Grouping is one of the rare fixes where the performance reason and the readability reason point the same way. That is why it comes first.

Case study 3: Salesforce turns slow loops into hard errors

Case study: Apex governor limits

Challenge. Salesforce runs customer code on shared, multitenant servers. One inefficient transaction could slow down everyone else on the platform.

Action. Salesforce's Apex Developer Guide explains that the runtime strictly enforces limits so runaway code can't monopolize shared resources, and it recommends design patterns such as bulk calls. The per-transaction limits include a maximum CPU time of 10,000 milliseconds for synchronous Apex and 60,000 milliseconds for asynchronous Apex.

Outcome. The platform converts wasteful loops into a hard failure at a published ceiling: the documentation says a limit violation produces a runtime exception that can't be handled. It also notes that CPU time excludes time spent in the database, so the limit mainly punishes work done in your own code, such as scanning loops. Salesforce doesn't publish a before-and-after story for a specific customer, so there is no measured improvement to quote. Evidence here is a documented constraint (Apex docs, Winter '27 release), not a benchmark.

Lesson. On some platforms, the cost of scanning instead of indexing isn't just slowness. It is an error at a fixed limit. [Practitioner guides on Apex best practices](https://blogs.perficient.com/2023/02/02/5-salesforce-best-practices-on-apex-code/) recommend using Maps to avoid nested loops, which is the same dictionary fix this article uses.

The pattern is not limited to one language. Oracle shows it as a NESTED LOOPS step in query plans, Salesforce shows it as a CPU limit, and Python shows it as a slow report. Different ecosystems, same question: is the inner side searched or indexed?

Common mistakes (and how to avoid them)

Common Python programming mistakes
Common Python programming mistakes

1. Swapping loops for a nested comprehension

This is the second uncommon insight: a comprehension is not a cure. It is shorter, not simpler. Here is the same manual join, compressed into one line.

Python: shorter, but still the Manual Join Smell:

report = {c.customer_id: sum((o.total * (Decimal("0.95") if c.is_vip else 1) for o in orders if o.customer_id == c.customer_id and o.status == OrderStatus.PAID), start=Decimal("0")) for c in customers}

It still scans every order for every customer, and now it is also unreadable. Comprehensions are for transforming one collection. They are a poor tool for joining two. The Python tutorial's section on list comprehensions shows what they are designed for.

2. Silencing the linter instead of reading it

A # pylint: disable comment makes the warning go away and leaves the cause in place. Treat the warning as the start of the investigation.

3. Optimizing before diagnosing

Rewriting the algorithm without asking the three questions usually moves the mess around. Diagnose first.

4. Letting the inner loop change the outer loop's state

One Reddit commenter named this as a concrete readability killer: an inner loop that modifies the outer loop's index while it runs. If you need that, the design is asking for a different shape.

5. Using flags to escape two loops

A break only exits the innermost loop, which pushes people toward found = False flags and extra checks. The cleaner Python answer is to extract the loops into a function and return from it.

Python: breaking out of nested loops with return:

def find_first_match(grid, target): for row_index, row in enumerate(grid): for col_index, value in enumerate(row): if value == target: return row_index, col_index return None

6. Over-splitting

A function called add_one that adds one is noise, not a name. Extract when the new name explains intent, not just because a line count looks high.

7. Using itertools.groupby on unsorted data

It is tempting as a group-by tool, but itertools.groupby groups consecutive equal keys, so the input must already be sorted by that key. In a quick local test, the keys a, b, a produced three separate groups unsorted, and two groups once sorted. For grouping unsorted records, the dictionary approach above is simpler and safer.

Warning: Don't treat any single rule here as a law. Extraction, grouping, and comprehensions are tools. Apply them when the question points to them, not by reflex.

Other design problems nested loops can point to

The three questions cover the most common causes, but real-world code reviews frequently reveal four additional anti-patterns. Each one is a quiet source of tangled loops.

Code review anti-patterns and loops
Code review anti-patterns and loops

Wrong abstraction level, or primitive obsession

If you pass around bare integers, strings, and floats instead of objects like Order, OrderItem, and Customer, the data has no defined shape, and you can end up with lists of lists of tuples that require nested loops to read. Refactoring Guru's page on primitive obsession describes the smell and its fixes in detail.

Fear of structure

Avoiding dictionaries, classes, and tuples at all costs makes control flow more complex, because the structure has to live somewhere. It ends up in the loops.

Incremental growth

One more condition, one more loop, and suddenly the code is deeply nested. This is not a check you do once at the end. It is something to keep in the back of your mind while coding. It also happens faster with generated code: our look at why AI-generated prototypes fail at production cutover covers how quickly "works but unmaintainable" code piles up when it is generated rather than designed.

Not knowing the Python tools

Comprehensions, sum(), the collections module, and itertools remove a lot of manual looping. For a deeper look at replacing loops with pipelines, Martin Fowler's essay on refactoring loops into collection pipelines is the classic reference, though its examples use Ruby and JavaScript.

Two related techniques deserve a mention. Guard clauses flatten nested if statements by exiting early, for example with continue at the top of a loop body. And Extract Function, which we used above, is available as a one-click refactoring in most editors.

5 signs your nested loops are hiding a design problem

  • The inner loop scans a whole collection to find matches for one outer item.
  • One block filters, calculates, and formats all at once.
  • You can't say in a few words what each level of the loop means.
  • Changing one business rule means editing the loop body.
  • The loop works with primitives and parallel lists instead of objects.

The checklist, and a decision rule to keep

Next time you meet nested loops and tangled conditions, don't start by trying to optimize or delete them. Ask:

  • Am I using the right data structure here?
  • Am I doing low-level work that doesn't belong at this level?
  • Does this function have too many responsibilities?
  • If one business rule changes, do I have to edit this function?
  • Can I split this up further?

The decision rule is short. If a nested loop mirrors the shape of your data, keep it and name it well. If it joins collections, mixes concerns, or collects reasons to change, fix the design around it. The loops that remain afterward are loops you chose.

A nested loop is a smoke detector, not a fire. Go and look at what triggered it.

Once the structure feels right, the next step is applying it somewhere larger. Our walkthrough on building and testing a production Python API puts these habits to work in a full project.

What usually causes deeply nested code in your projects? Tell us in the comments, because the patterns people report are often more useful than any rule.

FAQs: nested loops in Python

Are nested loops bad practice?

Not by themselves. They become a problem when they join unrelated collections, mix several concerns in one block, or nest so deeply that readers lose track. When the nesting mirrors the data, such as a grid, it is a natural choice. Treat a nested loop as a prompt to check the design, not as an automatic mistake.

How many nested loops is too many?

There is no fixed number. Two levels over two-dimensional data is normal. Three or more levels, or any level that scans an unrelated collection, deserves a design review. Pylint's default limit is five nested blocks, but what each level means matters more than how many there are.

How do I refactor nested loops in Python?

Ask three questions in order. First, is the data structure right? Group or index data with a dictionary instead of rescanning it. Second, is the behavior at the right level? Move object-specific logic onto the object. Third, does the function have too many reasons to change? Extract named functions until each has one.

Are nested loops always a performance problem (O(nΒ²))?

No. When two collections are scanned against each other, work grows with the product of their sizes, so small inputs are harmless and large ones can be painful. In one local test on CPython 3.12.3, a nested-scan report over 4,000 customers and 20,000 orders took about 2.9 seconds, versus about 0.04 seconds after grouping first. Results depend on your data and hardware, so measure your own case before rewriting.

Why is my linter complaining about too-many-nested-blocks?

Pylint raises too-many-nested-blocks (R1702) when a function nests more blocks than its limit, which defaults to five. The check counts nested blocks in general, and in a local test an if statement counted alongside for loops. Do not just suppress the warning. Use the three design questions to find out why the function got so deep.

Are list comprehensions always better than nested loops?

No. Comprehensions are good for transforming or filtering one collection. A nested comprehension that joins two collections is usually harder to read than the loop it replaced, and it still scans everything. Fix the data structure first, then use a comprehension where it fits.

When are nested loops the right tool?

When the nesting matches the data: walking a grid or matrix, comparing every pair, or reading a list of lists. Database engines also use nested loop joins on purpose when the inner side can be reached efficiently, as Oracle's 12.2 documentation describes. Use itertools.product if you want the same result with less indentation.

What is the difference between SRP and separation of concerns?

Separation of concerns is the broad idea of dividing a program into parts that each deal with one concern. The single responsibility principle applies that idea to a specific unit, such as a function or class, and frames it as having one reason to change. The two overlap heavily and differ mainly in level.

How do I check that my refactor did not change behavior?

Keep the old function next to the new one, run both on the same seeded test data, and assert that the outputs are equal. Also check that grouping loses no records by comparing the total group size with the input length. These checks show agreement on the data you tested, not on every possible input.

About The Author

A Gadallh

Ahmed Gadallah is the Founder and Editor of Vertex Frontier, where he publishes research-driven articles on AI, data science, cloud computing, cybersecurity, software engineering, and emerging technologies, with a focus on technical accuracy, clarity, and practical insights.

View all articles by A Gadallh →

Was this article helpful?

4 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

🏠 Home πŸ”– Saved πŸ“§ Join Us πŸ“€ Share ⬆️ To Top
Read Next Choosing a Python Data Library in 2026: A Field Guide Beyond the Cheat Sheets