Understanding Code Complexity: A Practical Guide for Engineering Teams

Code complexity silently kills velocity. Learn what cyclomatic and cognitive complexity actually measure, how to identify complexity hotspots, and practical strategies to keep your codebase maintainable.

Coderbuds Team
Coderbuds Team
Author

Last year we onboarded a senior engineer who spent their entire first week on a single task: understanding one function. It was 400 lines long, had 23 if statements, 7 levels of nesting, and handled everything from input validation to database writes to sending notifications. The function name was processData().

Nobody on the team could confidently explain what it did. People were afraid to touch it because every change broke something unexpected. Bug fixes in that function took 5x longer than equivalent fixes elsewhere in the codebase.

That function had a cyclomatic complexity score of 47. A healthy function scores under 10.

Code complexity is the silent tax on every engineering team. It slows down development, breeds bugs, makes onboarding painful, and eventually drives your best engineers to quit. The good news: it's measurable, and once you can measure it, you can manage it.

#What Is Code Complexity?

Code complexity measures how difficult a piece of code is to understand, test, and modify. It's not about how many lines of code you have—a 50-line function can be far more complex than a 200-line file.

Complexity arises from the number of paths through the code, the depth of nesting, the number of conditions that need to be evaluated, and how these elements interact with each other.

There are two dominant ways to measure it: cyclomatic complexity and cognitive complexity. They measure different things, and understanding both gives you a much clearer picture of your codebase health.

#Cyclomatic Complexity Explained

Cyclomatic complexity, developed by Thomas McCabe in 1976, counts the number of independent paths through a piece of code. Every decision point (if, else, switch case, loop, catch block) adds a new path.

#The Formula

The formal definition uses graph theory:

M = E - N + 2P

Where:

  • M = cyclomatic complexity
  • E = number of edges in the control flow graph
  • N = number of nodes in the control flow graph
  • P = number of connected components (usually 1 for a single function)

In practice, you don't need to draw graphs. There's a simpler counting method:

Start at 1. Add 1 for every: if, else if, &&, ||, for, foreach, while, case, catch, ternary operator.

#Low Complexity Example

 1function calculateDiscount(float $total, bool $isMember): float
 2{
 3    if ($isMember) {
 4        return $total * 0.9;
 5    }
 6
 7    return $total;
 8}

Cyclomatic complexity: 2 (one if statement). This function has two paths: member discount or no discount. Easy to understand, easy to test.

#High Complexity Example

 1function processOrder(Order $order): string
 2{
 3    if ($order->items->isEmpty()) {
 4        return 'empty';
 5    }
 6
 7    if ($order->customer === null) {
 8        return 'no_customer';
 9    }
10
11    $total = 0;
12    foreach ($order->items as $item) {
13        if ($item->isDiscounted() && $item->stock > 0) {
14            $total += $item->price * 0.8;
15        } elseif ($item->stock > 0) {
16            $total += $item->price;
17        } else {
18            if ($item->isBackorderable()) {
19                $total += $item->price * 1.1;
20            } else {
21                return 'out_of_stock';
22            }
23        }
24    }
25
26    if ($total > 100 && $order->customer->isPremium()) {
27        $total *= 0.95;
28    } elseif ($total > 500) {
29        $total *= 0.97;
30    }
31
32    if ($order->shippingMethod === 'express') {
33        $total += 15;
34    } elseif ($order->shippingMethod === 'overnight') {
35        $total += 30;
36    }
37
38    return number_format($total, 2);
39}

Cyclomatic complexity: 13. This function has 13 independent paths through it. To thoroughly test it, you need at least 13 test cases. And that's before you consider the combinations.

#What the Numbers Mean

  • 1-5: Simple. Easy to understand and test. This is where you want most of your code.
  • 6-10: Moderate. Manageable but starting to get complex. Consider whether it can be simplified.
  • 11-20: High. Difficult to test thoroughly. Refactoring is recommended.
  • 21-50: Very high. Almost certainly contains bugs. Needs to be broken apart.
  • 50+: Untestable. This function is a liability. It needs immediate attention.

The threshold most teams use: refactor anything above 10.

#Cognitive Complexity Explained

Cyclomatic complexity has a significant blind spot: it treats all code structures as equally difficult for humans to understand. But they're not.

Cognitive complexity, developed by SonarSource in 2016, measures how hard code is for a human brain to parse. It accounts for the fact that nested structures are harder to follow than sequential ones.

#How It Differs

Consider these two functions. They have the same cyclomatic complexity but wildly different cognitive complexity:

Function A (low cognitive complexity):

 1function getShippingRate(Order $order): float
 2{
 3    if ($order->isLocal()) {
 4        return 5.00;
 5    }
 6
 7    if ($order->isDomestic()) {
 8        return 10.00;
 9    }
10
11    if ($order->isInternational()) {
12        return 25.00;
13    }
14
15    return 15.00;
16}

Function B (high cognitive complexity):

 1function getShippingRate(Order $order): float
 2{
 3    if ($order->hasAddress()) {
 4        if ($order->address->country === 'US') {
 5            if ($order->address->state === $order->warehouse->state) {
 6                return 5.00;
 7            } else {
 8                return 10.00;
 9            }
10        } else {
11            return 25.00;
12        }
13    } else {
14        return 15.00;
15    }
16}

Both have a cyclomatic complexity of 4. But Function B is significantly harder to understand because of the nesting. Cognitive complexity captures this by adding a nesting penalty: each level of nesting increases the complexity score for any decision points within it.

#Cognitive Complexity Scoring Rules

  1. +1 for each: if, else if, else, ternary, switch, for, foreach, while, catch, &&, ||
  2. +1 nesting penalty for each level of nesting when a decision point is inside another decision point
  3. No penalty for structures that improve readability (early returns, guard clauses)

This means cognitive complexity actively rewards the patterns that make code more readable.

#Other Complexity Measures

While cyclomatic and cognitive complexity are the most widely used, there are other useful signals.

#Nesting Depth

Maximum nesting level in a function. Any function nested deeper than 3-4 levels is hard to follow. If you find yourself at 5+ levels, the function needs restructuring.

#Lines of Code (per function)

Not all long functions are complex, and not all short functions are simple. But function length correlates strongly with complexity. Keep functions under 20-30 lines as a guideline.

#Halstead Metrics

A family of metrics based on the number of operators and operands in code. Less commonly used today but still relevant for measuring vocabulary complexity and estimated effort.

#Coupling

How many other modules or classes a piece of code depends on. High coupling means a change in one place ripples through many others. This is architectural complexity rather than function-level complexity.

#Why Complexity Matters for Engineering Teams

#Bugs Correlate With Complexity

Research consistently shows that complex code contains more bugs. A study by Nagappan, Ball, and Zeller at Microsoft found that code complexity metrics were among the strongest predictors of post-release defects. Functions with high cyclomatic complexity had significantly more bugs per line of code.

This isn't surprising. More paths through the code means more opportunities for things to go wrong and more test cases needed to catch problems. When a function has 20+ paths, it's almost impossible to test all of them.

#Onboarding Costs Increase

Every new developer on your team needs to understand the codebase. Complex code takes longer to understand, which means slower onboarding. A study by Microsoft Research found that developers spend approximately 58% of their time understanding existing code. Complexity directly inflates that number.

#Review Difficulty Escalates

Code reviewers are less effective when reviewing complex code. They miss more bugs, take longer to review, and are more likely to rubber-stamp changes they don't fully understand. This compounds the bug problem—complex code is both more likely to have bugs and less likely to have those bugs caught in review.

#Refactoring Becomes Risky

Complex code is hard to refactor safely. Every change has unpredictable side effects because the developer can't hold the entire control flow in their head. Teams become afraid to touch complex code, which means it never improves—the complexity only grows.

#How to Measure Code Complexity

#Static Analysis Tools

PHPStan / Psalm (PHP): Static analysis tools that can report complexity metrics alongside type checking.

ESLint (JavaScript/TypeScript): The complexity rule flags functions exceeding a configurable cyclomatic complexity threshold.

 1{
 2    "rules": {
 3        "complexity": ["warn", 10]
 4    }
 5}

SonarQube / SonarCloud: Comprehensive code quality platforms that track both cyclomatic and cognitive complexity across your entire codebase, with trend analysis over time.

CodeClimate: Provides complexity analysis with letter grades and actionable improvement suggestions.

#CI/CD Integration

The most effective approach is integrating complexity checks into your CI/CD pipeline. When a PR increases complexity beyond a threshold, the build fails or a warning appears. This prevents complexity from creeping in one commit at a time.

Automated code review tools like Coderbuds can flag complexity issues directly on pull requests, catching problems during review before they reach the main branch.

#Complexity Thresholds and Benchmarks

Here are the thresholds we recommend:

Cyclomatic complexity per function:

  • Target: under 10
  • Warning: 11-15
  • Action required: 16+

Cognitive complexity per function:

  • Target: under 15
  • Warning: 16-25
  • Action required: 26+

Nesting depth:

  • Target: 3 levels maximum
  • Warning: 4 levels
  • Action required: 5+ levels

Function length:

  • Target: under 30 lines
  • Warning: 31-50 lines
  • Action required: 50+ lines

These aren't arbitrary numbers. They align with research on human cognitive limits and practical experience across thousands of engineering teams.

#Strategies to Reduce Complexity

#Extract Method

The single most effective refactoring technique. When a function is doing too many things, pull cohesive blocks of logic into separate, well-named functions.

Before (complexity: 8):

 1function processPayment(Payment $payment): bool
 2{
 3    if ($payment->amount <= 0) {
 4        return false;
 5    }
 6
 7    if ($payment->currency !== 'USD' && $payment->currency !== 'EUR') {
 8        return false;
 9    }
10
11    if ($payment->customer->isFraudulent()) {
12        Log::warning("Fraudulent customer attempt: {$payment->customer->id}");
13        return false;
14    }
15
16    $fee = $payment->amount > 1000 ? $payment->amount * 0.02 : $payment->amount * 0.03;
17    $total = $payment->amount + $fee;
18
19    // ... more processing
20}

After (complexity: 2 per function):

 1function processPayment(Payment $payment): bool
 2{
 3    if (! $this->isValidPayment($payment)) {
 4        return false;
 5    }
 6
 7    $total = $payment->amount + $this->calculateFee($payment);
 8
 9    // ... more processing
10}
11
12private function isValidPayment(Payment $payment): bool
13{
14    if ($payment->amount <= 0) {
15        return false;
16    }
17
18    if (! in_array($payment->currency, ['USD', 'EUR'])) {
19        return false;
20    }
21
22    if ($payment->customer->isFraudulent()) {
23        Log::warning("Fraudulent customer attempt: {$payment->customer->id}");
24        return false;
25    }
26
27    return true;
28}
29
30private function calculateFee(Payment $payment): float
31{
32    return $payment->amount > 1000
33        ? $payment->amount * 0.02
34        : $payment->amount * 0.03;
35}

The total code is slightly longer, but each function is simple and testable in isolation.

#Early Returns (Guard Clauses)

Instead of wrapping the happy path in nested conditions, handle error cases first and return early. This flattens the nesting and makes the main logic easier to follow.

Before:

 1function getUserProfile(int $userId): ?array
 2{
 3    $user = User::find($userId);
 4    if ($user) {
 5        if ($user->isActive()) {
 6            if ($user->hasProfile()) {
 7                return $user->profile->toArray();
 8            } else {
 9                return null;
10            }
11        } else {
12            return null;
13        }
14    } else {
15        return null;
16    }
17}

After:

 1function getUserProfile(int $userId): ?array
 2{
 3    $user = User::find($userId);
 4
 5    if (! $user) {
 6        return null;
 7    }
 8
 9    if (! $user->isActive()) {
10        return null;
11    }
12
13    if (! $user->hasProfile()) {
14        return null;
15    }
16
17    return $user->profile->toArray();
18}

Same behavior. Zero nesting. Dramatically lower cognitive complexity.

#Replace Conditionals With Polymorphism

When you find switch statements or long if/else chains that check the same variable, consider using polymorphism instead.

Before:

 1function calculateShipping(string $method, float $weight): float
 2{
 3    if ($method === 'standard') {
 4        return $weight * 0.5;
 5    } elseif ($method === 'express') {
 6        return $weight * 1.2 + 5;
 7    } elseif ($method === 'overnight') {
 8        return $weight * 2.0 + 15;
 9    } elseif ($method === 'international') {
10        return $weight * 3.5 + 25;
11    }
12
13    return 0;
14}

After:

 1interface ShippingCalculator
 2{
 3    public function calculate(float $weight): float;
 4}
 5
 6class StandardShipping implements ShippingCalculator
 7{
 8    public function calculate(float $weight): float
 9    {
10        return $weight * 0.5;
11    }
12}
13
14class ExpressShipping implements ShippingCalculator
15{
16    public function calculate(float $weight): float
17    {
18        return $weight * 1.2 + 5;
19    }
20}

Each class has a complexity of 1. Adding a new shipping method means adding a new class, not modifying an existing function.

#Dependency Injection

Functions that create their own dependencies internally are harder to test and often more complex. Inject dependencies instead, which simplifies the function and makes it testable.

#Code Complexity in Code Reviews

Complexity should be a first-class concern during code review. When reviewing a PR, ask:

Does this PR increase complexity in any function beyond our threshold? Tools can flag this automatically, but reviewers should also use their judgment.

Is the complexity necessary? Sometimes complex business rules demand complex code. That's acceptable if the complexity is well-structured and well-tested. But often, complexity is accidental—the result of rushed development or unfamiliarity with simpler approaches.

Could this be decomposed? If a new function has a cyclomatic complexity of 15, could it be three functions with a complexity of 5 each?

Automated code review tools like Coderbuds surface complexity metrics directly on pull requests, giving reviewers immediate visibility into how each change affects codebase complexity. This makes complexity a natural part of the review conversation rather than an afterthought.

#Complexity as a Team Metric

Individual function complexity matters, but the real value comes from tracking complexity trends across your codebase over time.

#Hotspot Analysis

Combine complexity data with change frequency. Files that are both complex and frequently changed are your highest-risk hotspots. These are where bugs are most likely to occur and where refactoring investment has the highest payoff.

#Trend Tracking

Plot average complexity per function over time. If the line trends upward, complexity is accumulating faster than you're paying it down. If it's flat or declining, your engineering practices are keeping complexity in check.

#Connection to Technical Debt

Complexity is one of the most concrete, measurable components of technical debt. When someone says "we have technical debt," they often mean "our code is too complex to work with efficiently." Complexity metrics put a number on that feeling.

#Practical Action Plan

This week:

  1. Run a complexity analysis on your codebase (SonarQube, ESLint, or PHPStan)
  2. Identify the 5 most complex functions
  3. Add complexity threshold rules to your linter config

This month: 4. Refactor the top 3 complexity hotspots 5. Add complexity checks to your CI/CD pipeline 6. Establish team norms for maximum complexity thresholds

Ongoing: 7. Review complexity metrics during sprint retrospectives 8. Track complexity trends alongside other engineering metrics 9. Celebrate complexity reductions as wins (they are)

Code complexity isn't glamorous to work on. Nobody gets excited about reducing a cyclomatic complexity score from 18 to 7. But the downstream effects are enormous: faster development, fewer bugs, happier engineers, and a codebase that your team actually wants to work in.

The best time to start managing complexity was when the codebase was new. The second best time is now.

Coderbuds Team
Written by

Coderbuds Team

The Coderbuds team writes about DORA metrics, engineering velocity, and software delivery performance to help development teams improve their processes.

View all posts

You're subscribed!

Check your email for a confirmation link. You'll start receiving weekly engineering insights soon.

Want more insights like this?

Join 500+ engineering leaders getting weekly insights on DORA metrics, AI coding tools, and team performance.

We respect your privacy. Unsubscribe anytime.