Ruff ships one complexity rule: C901, McCabe cyclomatic complexity, configured with max-complexity. It is the only complexity number most Python codebases ever see, and it is measuring something other than what people think it measures.
Here are two functions. One of them is fine and one of them is a problem. The metric that ships disagrees with you about which.
# A
def render(node: Node) -> str:
match node:
case Text(value): return escape(value)
case Bold(child): return f"<b>{render(child)}</b>"
case Italic(child): return f"<i>{render(child)}</i>"
case Link(href, ch): return f'<a href="{escape(href)}">{render(ch)}</a>'
case Heading(1, ch): return f"<h1>{render(ch)}</h1>"
case Heading(2, ch): return f"<h2>{render(ch)}</h2>"
case Heading(_, ch): return f"<h3>{render(ch)}</h3>"
case ListItem(ch): return f"<li>{render(ch)}</li>"
case Bullets(items): return "<ul>" + "".join(map(render, items)) + "</ul>"
case Code(value): return f"<code>{escape(value)}</code>"
case Rule(): return "<hr>"
case Break(): return "<br>"
# B
def collect(orders: list[Order], regions: set[str], blocked: set[str]) -> list[Item]:
out: list[Item] = []
for order in orders:
if order.region in regions:
for item in order.items:
if item.sku not in blocked:
out.append(item)
return out
A scores 13 on McCabe. B scores 5. By the only complexity metric your linter ships, the twelve-arm dispatch table is more than twice as complex as the quadruply-nested loop. Set max-complexity = 8 and you have just failed the readable one and passed the other.
What McCabe actually measures
Cyclomatic complexity is not a readability metric and was never claimed to be one. Thomas McCabe defined it in 1976 as the number of linearly independent paths through a function’s control-flow graph, which for structured code reduces to one plus the number of decision points. Its purpose was to answer a testing question: how many test cases do you need to cover every path?
For that purpose it is exactly right. Function A really does have thirteen paths and really does need thirteen tests. The metric is doing its job.
The mistake is using it as a proxy for “hard to understand”, because it counts every decision point identically. A case arm in a flat dispatch and an if buried three levels inside two loops each contribute 1. The metric has no concept of nesting, and nesting is most of what makes code hard to hold in your head.
💡If cyclomatic complexity counts paths and B has only 5, why does B feel harder to read than A? click to reveal
Because you do not read code by enumerating paths. You read it by maintaining a mental stack of the conditions currently in force.
In A, that stack is never deeper than one. To understand the Heading(2, ch) arm you need to know exactly one fact — that node is a Heading with level 2 — and you can verify each of the twelve arms in isolation, in any order, without holding the other eleven in mind. Twelve independent facts, each cheap. You can also scan it: the shape of the block tells you it is a dispatch table before you have read a single arm.
In B, understanding the innermost line requires holding four things simultaneously: which order you are in, that its region passed the filter, which item you are on, and that its SKU is not blocked. Nothing can be verified locally. And the cost is not linear — each level multiplies the state you are tracking rather than adding to it, which is why the fourth level of nesting is dramatically worse than the second.
This is also why the standard refactors work. An early continue (if order.region not in regions: continue) removes a level of nesting without removing a decision point. Ruff reports C901 complexity 5 before the refactor and 5 after — an identical score for code that is meaningfully easier to read. A metric that does not move when the code gets better is not measuring the thing you care about.
Cognitive complexity
Cognitive complexity, defined by Sonar in 2016, was designed to fix exactly this. It keeps McCabe’s “count the breaks in linear flow” idea and adds two corrections:
-
Ignore shorthand that lets you read many lines as one. A
switch/matchcosts +1 in total, regardless of how many arms it has, because a reader parses it as one construct. A null-coalescing chain, a comprehension, a ternary — cheap. -
Charge for nesting. Each structure gets +1, plus the current nesting depth.
elseandelifget a flat +1 with no nesting penalty, since they belong to a structure the reader has already paid for.
Score the two functions:
A: match ............................ +1 (twelve arms, still +1)
TOTAL 1
B: for order ........................ +1 (nesting 0)
if order.region .............. +1 +1 (nesting 1)
for item ................... +1 +2 (nesting 2)
if item.sku .............. +1 +3 (nesting 3)
TOTAL 10
A scores 1. B scores 10. That inverts McCabe’s verdict, and it matches what any reviewer would tell you.
Now refactor B. Invert both conditions into early continues — the loop body flattens, the ifs move up a level, and cognitive complexity drops from 10 to 8, while ruff’s C901 reports 5 before and 5 after. Go further and pull the inner loop into a select_items(order, blocked) helper, and the outer function drops to 3. The metric moves when the code improves and stays put when it does not, which is the entire requirement for a metric you intend to gate on.
(An unlabelled continue is not itself an increment under Sonar’s rules — only a continue or break to a label is. That is what makes the early-return style score better rather than merely differently.)
💡Cognitive complexity charges +1 for a match with twelve arms and +1 for a match with two. Is there any number of arms at which that stops being right?
click to reveal
Structurally, no — and that is a deliberate, defensible choice, not an oversight. Whether there are 12 arms or 60, the reader’s job is identical: find the one arm that matches and read it. The construct is a lookup table, and lookup tables do not get harder as they get longer.
But the design question absolutely does change with length, and cognitive complexity is simply not the instrument that answers it. Sixty arms in one function is usually telling you something specific — that this dispatch belongs on the types themselves (a method per node class, or a registry mapping type to handler), so that adding a node type is a local change rather than an edit to a function in a different module. That is an open/closed argument, not a readability argument, and no line-counting metric can make it for you.
There is one case where a long match genuinely does get harder, and it is worth naming because it is a real code smell: when the arms stop being parallel. Ten arms that each return a formatted string, plus one arm that mutates a cache, logs, and returns None — that eleventh arm is expensive to read no matter what the metric says, because the reader’s model of “every arm does the same kind of thing” has been violated and now every arm has to be checked individually.
Neither metric detects that. It is what code review is for.
What this course does
Both numbers are computed and both are displayed. Only one is gated.
-
McCabe, via ruff
C901,max-complexity = 8at Silver. It is kept mainly as a test-count sanity check: a function at 15 needs 15 tests, and if it has 3, that is the finding. - Cognitive complexity, nesting-weighted, gated at ≤ 15 at Silver and ≤ 10 at Gold.
When they disagree — and on well-factored code with dispatch tables they disagree constantly — the cognitive number is the one that will match your reviewer’s reaction.
A caution about both
Neither metric can see the thing that most often makes a function unreadable: bad names. def proc(d, f, x) with a cognitive complexity of 2 is worse to maintain than def collect_unblocked_items(orders, regions, blocked) at 10. Complexity metrics are floors, not ceilings — they catch the functions that are indefensible regardless of naming, which is genuinely useful, and they will never tell you that a simple function is doing the wrong thing.
Use them to find the worst 1% automatically. Use review for everything else.