Back to blog

Engineering

What Code Review Actually Catches (That CI Does Not)

5 min read
Abstract visualization of code paths and review signals

Your CI pipeline is green. Every lint rule passes. The test suite completes in under four minutes. The PR gets a quick look and merges. Forty-eight hours later, you're paging the on-call engineer because a null dereference crashed the payment processor in production.

This is not an unusual scenario. It is the ordinary failure mode of teams that conflate "CI passes" with "code is safe to ship." The two are not the same thing, and understanding the difference is the starting point for building a review process that actually works.

What CI and Linters Are Good At

Continuous integration tools excel at mechanical verification. They check that the code compiles, that your declared types align (in statically typed languages), that you haven't introduced a syntax error, and that the test cases you already wrote still pass. Static analysis tools like ESLint, Pylint, and Checkstyle enforce style conventions, flag known anti-patterns, and catch a narrow set of runtime-safety issues that map cleanly to AST rules.

These tools are genuinely valuable. Nobody should ship code without them. But their strength -- deterministic, repeatable, rule-based checks -- is precisely where their limit lies. They can only catch what someone already thought to write a rule for.

The Category of Bugs That Slip Through

The bugs that reach production despite green CI tend to fall into a handful of predictable categories:

Null and Undefined Dereferences

TypeScript's type system catches many null issues at compile time, but not all. Consider a function that accepts a typed object and chains several property accesses on it. If an upstream change makes one intermediate property optional -- a change that TypeScript permits under certain configuration -- the type checker may not flag the access downstream. The linter sees valid TypeScript. CI passes. The runtime sees an exception when that optional property comes back undefined from the database.

Linters operating on local AST analysis miss cases where the nullability arises from a contract change elsewhere in the codebase. Catching this requires cross-module data flow understanding, not just local token inspection.

Behavioral Regressions in Untested Paths

Test coverage metrics measure which lines execute during the test suite, not which behaviors are actually verified. A function with 90% line coverage can still have a major untested path: the branch that handles the edge case your test fixtures never hit.

When a PR modifies a function that is called from 12 different places, CI verifies that the existing tests still pass. It does not verify that the modification didn't subtly change the semantics for call sites 9 through 12, which happen to take a different code path through the function. A reviewer who understands the codebase would check those other call sites. An automated rule cannot, because the rule doesn't have the semantic context to know which call sites matter.

Logic Errors in Complex Conditionals

Consider a conditional with four clauses joined by && and || operators where one clause was added by a previous developer to handle a specific production incident. A new PR modifies the surrounding logic for an unrelated reason and inadvertently changes the evaluation order. The tests that cover the main path still pass. The edge case from the original incident is no longer protected.

Linters flag things like == null versus === null, but they don't track the intent behind a conditional that evolved over six months of incidents and fixes. That is the kind of understanding that comes from reading code with context.

Race Conditions and Incorrect Concurrency

Static analysis tools have made progress on concurrency issues in some languages -- Java's ThreadSafe annotations, Go's race detector, Rust's borrow checker. But in JavaScript, Python, and other languages where async patterns are idiomatic but not enforced by the type system, incorrect usage of await, missed mutex acquisition, and shared mutable state are largely invisible to automated tools. They show up as intermittent failures in production that are extremely expensive to debug.

What Code Review Actually Does

When code review functions well, it applies the kind of semantic understanding that rules-based tools cannot. An experienced reviewer reading a PR doesn't just verify that the new code looks syntactically correct; they ask questions about the code's intended behavior and its interactions with the rest of the system.

They notice that a new database query doesn't handle the case where the result set is empty and the downstream code assumes at least one row. They flag that an API response is being passed directly into a deserialization call without validation, creating an injection surface. They see that a timer is started but the corresponding cleanup is conditional on a path that might not execute.

None of these observations require a specific lint rule to exist. They require contextual reasoning.

Where Human Review Breaks Down

The practical problem is that human reviewers are expensive, inconsistent, and subject to fatigue. A reviewer looking at their fifteenth PR of the day is less likely to notice the subtle null-path issue than the reviewer reading the first PR of the morning. Teams under deadline pressure approve PRs faster. Late-Friday merges get less scrutiny.

This inconsistency is not a discipline problem; it is a cognitive resource problem. Review quality degrades predictably under load, and most engineering teams operate near their load limits most of the time.

The Practical Gap This Creates

The result is a two-tier review system in practice, even if nobody designed it that way. CI handles the mechanical checks with perfect consistency. Human review handles the semantic checks with imperfect consistency. The bugs that live in the gap -- the ones that require semantic reasoning but are subtle enough to escape an overloaded reviewer -- are exactly the bugs that reach production.

Closing that gap doesn't require replacing human reviewers. It requires augmenting them with a consistent first pass that applies semantic analysis to every PR, regardless of what time it is or how full the review queue has grown. When reviewers know that the null-check scan has already run, they can focus their attention on the architectural decisions and domain logic that genuinely require human judgment.

That is what code review actually catches, and why the most valuable investment in your review process is not a longer checklist -- it is a smarter first filter.