Skip to content
← All Python articles

Python explainer

Find Hard-to-Follow Functions with Complexity Scores

About 4 min to read. Allow extra time to work through examples.

Cyclomatic complexity counts the decisions in a function, and cognitive complexity also counts how far they nest. Score two equivalent functions by hand, and see what each number tells a reviewer.

This article uses Python. You can make it your practice mode to browse related content across the site.

A reviewer says this function is hard to follow and asks you to simplify it. It collects the items from orders in some regions, and skips blocked items:

from dataclasses import dataclass


@dataclass
class Order:
    region: str
    items: list[str]


def collect_nested(
    orders: list[Order], regions: set[str], blocked: set[str]
) -> list[str]:
    out: list[str] = []
    for order in orders:
        if order.region in regions:
            for item in order.items:
                if item not in blocked:
                    out.append(item)
    return out

A complexity score puts a number on how hard a function is to follow. Two scores are common, and they count different things. This site does not measure either one in exercises, so treat them as tools for your own projects.

Cyclomatic complexity counts decisions

Cyclomatic complexity measures how many separate paths run through a function. For a simple function, start at 1 and add 1 for each if, elif, for and while. collect_nested has two loops and two if statements, so it scores 1 + 4 = 5.

Ruff's C901 rule flags each function that scores above a limit. The limit is 10 unless you set your own in pyproject.toml:

[tool.ruff.lint.mccabe]
max-complexity = 8

Here is an equivalent version that skips unwanted cases early:

def collect_flat(
    orders: list[Order], regions: set[str], blocked: set[str]
) -> list[str]:
    out: list[str] = []
    for order in orders:
        if order.region not in regions:
            continue
        for item in order.items:
            if item in blocked:
                continue
            out.append(item)
    return out

It still has two loops and two if statements, so it also scores 5. It nests less, though. Every order that reaches the item loop is already in a wanted region. Check that the two versions agree:

orders = [Order("west", ["tea", "coffee", "tea"]), Order("east", ["bread"])]
assert collect_nested(orders, {"west"}, {"coffee"}) == ["tea", "tea"]
assert collect_flat(orders, {"west"}, {"coffee"}) == ["tea", "tea"]

Cognitive complexity also counts nesting

Cognitive complexity comes from Sonar, the company behind the SonarQube code checker. It adds points for nesting. Each if or loop adds 1, plus 1 for each if or loop it sits inside. A continue adds nothing. Counted by hand with Sonar's rules, the two versions score differently:

  • collect_nested scores 1 + 2 + 3 + 4 = 10. The outer loop adds 1, the if inside it adds 2, the inner loop adds 3, and the innermost if adds 4.
  • collect_flat scores 1 + 2 + 2 + 3 = 8, because its inner loop and the if inside that loop each sit one level shallower.

The lower score matches what a reader has to track. In the nested version, out.append(item) runs inside two loops and two if statements. In the flat version, it runs inside only the two loops, because each if has already ended with continue.

Check your understanding Moving the inner loop into a helper function lowers the score of collect_nested. Is the code now easier to follow? Show answer

Only if the helper is an idea worth naming. The reader still has to understand the helper, so the total work may not drop. A helper such as allowed_items(order, blocked) helps, because its name says what it returns. Splitting code into small, vaguely named functions to get under a limit makes it harder to follow.

A score is not a test count

A score of 5 does not mean five tests are enough. A loop can run zero, one or many times, and the values you pass in create cases that no branch count shows. For these collectors, test these cases:

  • an empty list of orders,
  • an order from a region you did not ask for,
  • an order with no items,
  • a blocked item,
  • a repeated item, which should appear twice,
  • several accepted orders, to check that the output keeps their order.

Use the score to start a review

Run C901 as advice first, as the previous article showed, and read what it flags. Fix confusing names, repeated conditions and deep nesting where the change helps. A long, flat match that maps each command to one function call can score high and still be easy to read. When a clear function goes over the limit, keep it, and add # noqa: C901 to its def line with a comment that says why.

Going further

Cyclomatic complexity comes from a 1976 paper by Thomas McCabe. That is why Ruff puts the settings for C901 in a section named mccabe. Sonar's full rules for cognitive complexity also count sequences of and and or, and recursion. Python tools that compute cognitive complexity can count some constructs differently, so check a tool's documentation before you compare its numbers with these.

step 37 of 41

Modules, Packaging and Tooling

Up next Turn On mypy Checks Beyond --strict