Alex Vakhitov

software engineering4 min read

Cyclomatic complexity in Python, measured with ast

By Alex Vakhitov

Maze of winding paths with a small snake, a diagram of branching control flow

What is cyclomatic complexity?

Cyclomatic complexity is a metric for how complicated a piece of code is. It counts the number of linearly independent paths through a program's source code. In simple terms, it measures how complicated your code is by counting its decision points, such as if, while and for statements. An else doesn't add a path of its own: an if with an else still has two paths, the same as an if without one.

Thomas J. McCabe introduced cyclomatic complexity in 1976. For a program's control-flow graph, the complexity M is:

M=E−N+2PM = E - N + 2P
  • E = the number of edges
  • N = the number of nodes
  • P = the number of connected components (1 for a single function)

For a single function this works out to the number of decision points plus one, which is what tools actually count.

Examples

  • Low complexity: a function that adds two numbers. Cyclomatic complexity: 1.
  • Medium complexity: a function with an if-else statement. Cyclomatic complexity: 2.
  • Higher complexity: a function with several control structures. The classify function in the sample below scores 5.

Why measure it?

  • Maintainability: higher complexity usually means code that's harder to maintain.
  • Testing: the complexity is the number of independent paths, so it's a guide to how many test cases you need to cover them.
  • Readability: simpler code is generally easier to read and understand.

McCabe suggested 10 as a sensible upper limit for a single function, and many teams still use it as a default threshold.

Analysing Python code with abstract syntax trees

Python's ast module parses source code into an abstract syntax tree (AST): a tree of nodes representing the grammatical constructs in the code, such as If, For and BoolOp. That makes it straightforward to analyse code programmatically, without writing a parser.

Implementation

This script counts the decision points in each function and adds one. It counts:

  • if statements and conditional expressions (x if cond else y);
  • for, async for and while loops;
  • each except handler;
  • each comprehension loop, and each if inside a comprehension;
  • boolean operators: a and b and c adds two, one for each extra condition;
  • match statements: one per case, not counting a final catch-all case _:.

It measures nested functions, lambdas and classes separately rather than adding them to the outer function.

import ast
import sys

FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
NESTED_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)


def decision_points(node):
    """Count the decision points directly inside one function body."""
    count = 0
    for child in ast.iter_child_nodes(node):
        if isinstance(child, NESTED_SCOPES):
            continue  # nested functions and classes are measured separately
        if isinstance(child, (ast.If, ast.IfExp, ast.For, ast.AsyncFor,
                              ast.While, ast.ExceptHandler, ast.comprehension)):
            count += 1
        if isinstance(child, ast.comprehension):
            count += len(child.ifs)  # each `if` in a comprehension is a branch
        elif isinstance(child, ast.BoolOp):
            count += len(child.values) - 1  # `a and b and c` adds two
        elif isinstance(child, ast.Match):
            # One branch per case; a final `case _:` is the fall-through, not a new branch.
            count += len(child.cases)
            last = child.cases[-1].pattern
            if isinstance(last, ast.MatchAs) and last.pattern is None and last.name is None:
                count -= 1
        count += decision_points(child)
    return count


def cyclomatic_complexity(function_node):
    # McCabe's M = E - N + 2P reduces to "decision points + 1" for one function.
    return decision_points(function_node) + 1


def analyse_file(path):
    with open(path, encoding="utf-8") as source:
        tree = ast.parse(source.read(), filename=path)
    results = []
    for node in ast.walk(tree):
        if isinstance(node, (*FUNCTION_NODES, ast.Lambda)):
            name = node.name if isinstance(node, FUNCTION_NODES) else "<lambda>"
            results.append((node.lineno, name, cyclomatic_complexity(node)))
    for lineno, name, complexity in sorted(results):
        print(f"{path}:{lineno} {name}: {complexity}")


if __name__ == "__main__":
    for path in sys.argv[1:]:
        analyse_file(path)

Tested with Python 3.13 and 3.14.

Example

Here's a small file to analyse, sample.py:

def add(a, b):
    return a + b


def sign(x):
    if x < 0:
        return -1
    else:
        return 1


def classify(reading, limits):
    if reading is None or reading < 0:
        return "invalid"
    for limit in limits:
        if reading > limit:
            return "high"
    return "normal"


def parse_all(lines):
    results = []
    for line in lines:
        try:
            results.append(int(line))
        except ValueError:
            continue
    return [r for r in results if r > 0 and r < 100]

Running python cyclomatic.py sample.py prints:

sample.py:1 add: 1
sample.py:5 sign: 2
sample.py:12 classify: 5
sample.py:21 parse_all: 6

classify scores 5: one for the if, one for the or, one for the for, one for the inner if, plus one. parse_all scores 6: the for loop, the except handler, the comprehension loop, its if, and the and, plus one. These numbers match what radon, a widely used Python complexity tool, reports for the same file.

Tools you can use instead

Writing it yourself is a good way to understand the metric, but for real projects use an existing tool:

  • radon: radon cc -s your_file.py reports complexity per function, with a letter grade.
  • flake8 with its built-in mccabe plugin: flake8 --max-complexity 10 flags any function above the threshold, which is easy to add to continuous integration.

Summary

Cyclomatic complexity is a quick, objective signal for readability, maintainability and testability. With Python's ast module you can calculate it for any file in a few dozen lines, and use the results to decide where refactoring and extra tests will help most. It's also one way to spot deep nesting and other anti-patterns.

Get new notes by email.