Cyclomatic complexity: what the number actually tells you
The build log has carried the same warning for months: cyclomatic complexity 14, threshold 10. The method works. It shipped a while ago, nobody has touched it since, and the warning has been sitting there like a parking ticket on a car nobody drives. Whether you should care depends entirely on what you think the number measures, and most explanations skip that part.
Cyclomatic complexity is one of the oldest metrics in software engineering. Thomas McCabe published it in 1976, almost a quarter century before C# was a glint in anyone's eye, and it has outlived hundreds of trendier metrics for one reason: it counts something real.
What cyclomatic complexity counts
Picture your method as a flow graph. Every statement is a node, and every place the code can go one way or another splits the graph into branches. Cyclomatic complexity is the number of independent paths through that graph. In practice you never draw the graph, because the number collapses to an easier rule: count the decisions, add one.
The conditional ladder
decision points: 4, complexity 5
The table lookup
decision points: 1, complexity 2
A method with no branching has complexity 1: there is exactly one way through it. Every if,
else if, case, while, for, foreach, catch, ternary, &&, and || adds one, because
each of them creates one more way the execution can fork. The else is free; it is the other
side of a fork you already counted.
Why should you care about path count? Because each independent path is a distinct behavior your method can exhibit, and a distinct behavior is something you have to test. A method with complexity 8 needs at least eight test cases before you can honestly claim every branch decision has been exercised in both directions along independent paths. That is the most useful reading of the metric, and the one worth internalizing:
Cyclomatic complexity is a bill, not a grade. It counts the test cases you owe before you can claim to have exercised every path through the method at least once.
Counting it by eye
Here is a method you have written a hundred times, in one costume or another:
public decimal CalculateShipping(Order order)
{
if (order is null) // +1
{
throw new ArgumentNullException(nameof(order));
}
decimal rate;
if (order.WeightKg > 20 && order.IsFragile) // +1 for if, +1 for &&
{
rate = 24.00m;
}
else if (order.WeightKg > 20) // +1
{
rate = 18.00m;
}
else if (order.WeightKg > 5) // +1
{
rate = 9.50m;
}
else
{
rate = 4.90m; // else is free
}
foreach (var surcharge in order.Surcharges) // +1
{
rate += surcharge.Amount;
}
return order.IsPriority ? rate * 1.5m : rate; // +1
}
Walk it: the null guard is one, the compound condition is two (the if and the && are separate
forks), the two else if arms are one each, the loop is one, and the ternary at the end is one.
Seven decisions, plus one: complexity 8. Eight paths, eight test cases owed. Suddenly the
number is not an abstract score; it is the length of the test list you were probably not planning
to write.
Why 10, and what the number predicts
The folk threshold of 10 comes straight from McCabe's paper, and he was refreshingly honest about it: it was a pragmatic limit his team settled on, not a law of nature. Fifty years of use have kept it around because it sits near a real inflection point. Below 10, most people can hold the method's behavior in their head. Above it, they start simulating the method instead of reading it, and the test list grows past what anyone actually writes.
Be careful about what the metric predicts, though. High complexity correlates with defects, but a big part of that correlation is just size: complex methods tend to be long methods, and long methods contain more code to be wrong in. What the number predicts directly and mechanically is testing effort. A complexity of 14 does not mean the method is broken. It means fourteen paths exist, your test suite covers some unknown subset of them, and every uncovered path is a behavior that ships unverified.
What the number misses
Two methods can share a score and read completely differently. A switch with twelve short
cases scores 13 and reads like a lookup table, which is what it is. A method with three loops
nested inside two conditionals can score less than that and still take you ten minutes to trace,
because nesting multiplies the state you carry in your head while the metric just adds.
That gap is what cognitive complexity
was invented to close. SonarSource's variant charges extra for nesting, charges a switch once
instead of once per case, and lets flat sequences of early returns off cheaply. If your tooling
offers both numbers, read them as a pair: cyclomatic for the size of the test bill, cognitive
for how much the method will hurt to read while you pay it.
Reducing cyclomatic complexity without cheating
The moves below are ranked by honesty: what each one actually does to the number, recomputed.
Guard clauses. The classic arrow of nested conditionals:
public string AccessLevel(User user)
{
if (user is not null) // +1
{
if (user.IsActive) // +1
{
if (user.HasSubscription) // +1
{
return "full";
}
return "trial";
}
return "locked";
}
return "anonymous";
}
Three decisions, complexity 4. Flip it into guards:
public string AccessLevel(User user)
{
if (user is null) return "anonymous"; // +1
if (!user.IsActive) return "locked"; // +1
if (!user.HasSubscription) return "trial"; // +1
return "full";
}
Still three decisions, still complexity 4. The cyclomatic number does not move at all, and it is worth being honest about that. What moved is the nesting: the cognitive score drops by roughly half, and the method now reads as a checklist instead of a staircase. Guard clauses are a readability refactoring that the cyclomatic metric cannot see, which tells you something about the metric.
Replace the conditional ladder with a table. This one genuinely deletes decisions:
public decimal MonthlyRate(string plan)
{
if (plan == "free") return 0m; // +1
if (plan == "starter") return 9m; // +1
if (plan == "pro") return 29m; // +1
if (plan == "team") return 79m; // +1
throw new ArgumentException($"Unknown plan: {plan}");
}
Four decisions, complexity 5, and every new plan adds another. Move the mapping into data:
private static readonly Dictionary<string, decimal> Rates = new()
{
["free"] = 0m,
["starter"] = 9m,
["pro"] = 29m,
["team"] = 79m,
};
public decimal MonthlyRate(string plan) =>
Rates.TryGetValue(plan, out var rate) // +1
? rate
: throw new ArgumentException($"Unknown plan: {plan}");
One decision, complexity 2, and it stays 2 when the fifth plan arrives, because new cases now change data instead of control flow. The object-oriented flavor of the same move is polymorphism: when the ladder switches on a type, give each type its own class and let dispatch do the branching. Each implementation carries a complexity of 1 or 2, and the ladder disappears.
Extract method. The honest framing here is division, not subtraction. Take the shipping method from earlier at complexity 8 and pull the rate ladder out:
public decimal CalculateShipping(Order order)
{
if (order is null) throw new ArgumentNullException(nameof(order)); // +1
var rate = BaseRate(order); // ladder lives there now
foreach (var surcharge in order.Surcharges) // +1
{
rate += surcharge.Amount;
}
return order.IsPriority ? rate * 1.5m : rate; // +1
}
The outer method drops to 4 and BaseRate scores 5. Note the sum went up by one, because every
method starts at 1. You did not destroy any paths; you gave a coherent subset of them a name, a
signature, and a small test surface you can cover in isolation. That is still a real win. It is
just a different win than the table, and knowing which one you are making keeps you honest.
The number can be gamed, which is the point
You can launder complexity past most analyzers without removing any of it. Bury decisions in
LINQ lambdas that some tools skip. Replace explicit guards with helper calls like
ArgumentNullException.ThrowIfNull that hide the branch. Push the ladder into configuration
that no static analysis will ever count, so the paths still exist but only at runtime. Every one
of these makes the dashboard greener and the method no easier to test.
Which is exactly why the number should never be the goal. The goal is fewer paths per unit of understanding: methods whose behaviors you can enumerate, test, and hold in your head. The number is a decent proxy for that until someone starts optimizing the proxy. Treat it the way you treat a fuel gauge: worth glancing at, dishonest the moment you tape a photo of "full" over it. Complexity is also one of the few code smells a machine can see, which makes it a good early warning as long as you remember it is only that.
Practicing against a grader that counts
Reading about path counts is one thing. Feeling a 14 collapse to a pair of 4s under your own hands is another, and that second thing is trainable. Katabench's refactoring track hands you a working method with ugly structure and a green test suite, then measures your rewrite with Roslyn: complexity, nesting depth, and method length, computed from your actual source while the tests pin the behavior in place. Gaming the number is much harder there: the grading scores complexity, nesting, and length together while the tests hold the behavior still, so the usual laundering moves cost you on another axis.
The counting becomes automatic sooner than you would expect. You see an else if ladder and your head
says "six, and it will be seven by March" before you have consciously read it. That instinct,
not any particular threshold, is what the metric was always for. If you want the wider picture of
which structural numbers are worth tracking, measuring code quality
covers the family, and refactoring legacy C# shows the moves
at production scale.