Katabench
Start free
← All posts

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

entry ? return ? return ? return ? return throw

decision points: 4, complexity 5

The table lookup

entry ? throw rate = table[key] return

decision points: 1, complexity 2

Same behavior, same tests. Five paths collapsed to two, and the graph is the receipt.

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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.