software engineering4 min read
Cyclomatic complexity in Python, measured with ast

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:
- 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-elsestatement. Cyclomatic complexity: 2. - Higher complexity: a function with several control structures. The
classifyfunction 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:
ifstatements and conditional expressions (x if cond else y);for,async forandwhileloops;- each
excepthandler; - each comprehension loop, and each
ifinside a comprehension; - boolean operators:
a and b and cadds two, one for each extra condition; matchstatements: one percase, not counting a final catch-allcase _:.
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.pyreports complexity per function, with a letter grade. - flake8 with its built-in mccabe plugin:
flake8 --max-complexity 10flags 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.