Blog

Static code analysis vs AI code review: what each catches

Where rule-based checks stop and AI review on the pull request picks up, with real tool output and open-source analyzers for common languages.

Alex Mercer

Static code analysis is the automated checking of source code without running it. A tool parses your code into a structure it can inspect, then applies rules that flag bugs, security weaknesses, type errors and style problems.

In AI code review, a large language model reads a pull request together with the code around it and comments on problems that no rule describes, such as a change in one file that breaks an assumption in another. The two find different classes of bugs, and each one is cheapest at a different point in your workflow.

What static code analysis is

The static analysis chapter of Software Engineering at Google defines it as "programs analyzing source code to find potential issues such as bugs, antipatterns, and other issues that can be diagnosed without executing the program." Static means the program never runs. Tests, fuzzers and sanitizers run it, which makes them dynamic analysis.

In practice the term covers tools you probably run already:

  • Linters flag suspicious patterns and style problems: ESLint for JavaScript, Ruff and Pylint for Python, RuboCop for Ruby.

  • Type checkers make sure values are used the way their types allow: mypy for Python, the TypeScript compiler in strict mode.

  • Compilers reject some mistakes outright. Go's compiler treats an unused variable or import as an error and stops the build.

  • Security linters match known vulnerability shapes, such as SQL assembled from strings.

How static analysis works

Most analyzers start where a compiler starts. A lexer turns source text into tokens, and a parser builds an abstract syntax tree (AST) from them: a tree of the program's structure in which every operator, call and name is a node. Python exposes its own parser through the standard ast module, so you can print the kind of tree a Python linter works on:

>>> import ast
>>> print(ast.dump(ast.parse("customer == None", mode="eval").body))
Compare(left=Name(id='customer', ctx=Load()), ops=[Eq()], comparators=[Constant(value=None)])
>>> import ast
>>> print(ast.dump(ast.parse("customer == None", mode="eval").body))
Compare(left=Name(id='customer', ctx=Load()), ops=[Eq()], comparators=[Constant(value=None)])
>>> import ast
>>> print(ast.dump(ast.parse("customer == None", mode="eval").body))
Compare(left=Name(id='customer', ctx=Load()), ops=[Eq()], comparators=[Constant(value=None)])

A rule is a check that runs on nodes of that tree. Ruff's rule E711 looks for this shape, an equality comparison with None on one side, and asks you to write customer is None. ESLint works the same way for JavaScript: a rule returns functions that ESLint calls to visit nodes as it walks the tree (custom rules docs). PMD parses Java into an AST and runs rules written in Java or as XPath queries against it. Bandit builds an AST for each Python file and runs its security plugins on the nodes.

Shapes only go so far. Deeper analyzers also track what values can be:

  • A type checker uses your annotations to work out what each expression can hold. mypy knows a str | None value may be None, so it rejects a call to .split() on it until you check.

  • Control flow analysis turns a function into a graph of basic blocks joined by jumps, and data flow analysis follows values along the paths through that graph.

  • Taint analysis, a data flow technique built for security, marks user-controlled input and flags it when it reaches a sink, such as a query, without being sanitized (OWASP).

Deeper analysis is slower. The Clang Static Analyzer documentation says static analysis "can be much slower than compilation" and that some of the algorithms it needs take exponential time in the worst case, so the analyzer bounds how much checking it does.

What static analysis catches reliably

Take a small file like this one:

from dataclasses import dataclass


@dataclass
class Customer:
    name: str
    email: str | None


def email_domain(customer: Customer) -> str:
    if customer == None:
        return ""
    return customer.email.split("@")[1]


def add_tag(tag: str, tags=[]) -> list[str]:
    tags.append(tag)
    return tags


def find_orders(db, customer_id: str):
    return db.execute(f"SELECT * FROM orders WHERE customer_id = '{customer_id}'")
from dataclasses import dataclass


@dataclass
class Customer:
    name: str
    email: str | None


def email_domain(customer: Customer) -> str:
    if customer == None:
        return ""
    return customer.email.split("@")[1]


def add_tag(tag: str, tags=[]) -> list[str]:
    tags.append(tag)
    return tags


def find_orders(db, customer_id: str):
    return db.execute(f"SELECT * FROM orders WHERE customer_id = '{customer_id}'")
from dataclasses import dataclass


@dataclass
class Customer:
    name: str
    email: str | None


def email_domain(customer: Customer) -> str:
    if customer == None:
        return ""
    return customer.email.split("@")[1]


def add_tag(tag: str, tags=[]) -> list[str]:
    tags.append(tag)
    return tags


def find_orders(db, customer_id: str):
    return db.execute(f"SELECT * FROM orders WHERE customer_id = '{customer_id}'")

Run Ruff with its pycodestyle, Pyflakes, bugbear and bandit rule sets (E, F, B and S), then mypy:

$ ruff check --select E,F,B,S --output-format concise orders.py
orders.py:11:20: E711 Comparison to `None` should be `cond is None`
orders.py:16:28: B006 Do not use mutable data structures for argument defaults
orders.py:22:23: S608 Possible SQL injection vector through string-based query construction
Found 3 errors.
No fixes available (2 hidden fixes can be enabled with the `--unsafe-fixes` option).
$ mypy orders.py
orders.py:13: error: Item "None" of "str | None" has no attribute "split"  [union-attr]
Found 1 error in 1 file (checked 1 source

$ ruff check --select E,F,B,S --output-format concise orders.py
orders.py:11:20: E711 Comparison to `None` should be `cond is None`
orders.py:16:28: B006 Do not use mutable data structures for argument defaults
orders.py:22:23: S608 Possible SQL injection vector through string-based query construction
Found 3 errors.
No fixes available (2 hidden fixes can be enabled with the `--unsafe-fixes` option).
$ mypy orders.py
orders.py:13: error: Item "None" of "str | None" has no attribute "split"  [union-attr]
Found 1 error in 1 file (checked 1 source

$ ruff check --select E,F,B,S --output-format concise orders.py
orders.py:11:20: E711 Comparison to `None` should be `cond is None`
orders.py:16:28: B006 Do not use mutable data structures for argument defaults
orders.py:22:23: S608 Possible SQL injection vector through string-based query construction
Found 3 errors.
No fixes available (2 hidden fixes can be enabled with the `--unsafe-fixes` option).
$ mypy orders.py
orders.py:13: error: Item "None" of "str | None" has no attribute "split"  [union-attr]
Found 1 error in 1 file (checked 1 source

E711 is a PEP 8 style rule. The other three findings are bugs. add_tag shares one list across every call, because Python evaluates a default argument once, when the function is defined. email_domain crashes for a customer with no email. The f-string puts customer_id straight into SQL, an injection hole if that value ever comes from a user.

Both tools finished within about a second on this file, and they report the same findings on every run as long as the code and config stay the same. Because the output is deterministic, you can fail a commit or a CI job on it without anyone reading it first.

Rules also cover a lot of ground: go vet checks that Printf arguments match the format string, SpotBugs checks Java for more than 400 bug patterns, Clippy ships over 800 lints, and style checkers like Checkstyle and RuboCop settle naming and formatting before a reviewer sees the code. OWASP rates security scanners as great "for things that such tools can automatically find with high confidence, such as buffer overflows, SQL Injection Flaws."

What static analysis misses

Rules see what is written in the code: syntax, types and data flow. They don't know why a change exists, and they only know what the rest of the system assumes when a type or a rule spells it out.

Logic that spans files

This pull request moves plan prices from dollars to cents:

# billing/prices.py (changed in this pull request)
PLAN_PRICES = {"starter": 2900, "team": 9900}


def plan_price(plan: str) -> int:
    """Monthly price per seat, in cents."""
    return PLAN_PRICES[plan]
# billing/prices.py (changed in this pull request)
PLAN_PRICES = {"starter": 2900, "team": 9900}


def plan_price(plan: str) -> int:
    """Monthly price per seat, in cents."""
    return PLAN_PRICES[plan]
# billing/prices.py (changed in this pull request)
PLAN_PRICES = {"starter": 2900, "team": 9900}


def plan_price(plan: str) -> int:
    """Monthly price per seat, in cents."""
    return PLAN_PRICES[plan]
# billing/invoice.py (not part of the diff)
from billing.prices import plan_price


def invoice_line(plan: str, seats: int) -> str:
    total = plan_price(plan) * seats
    return f"{plan} x{seats}: ${total:,.2f}"
# billing/invoice.py (not part of the diff)
from billing.prices import plan_price


def invoice_line(plan: str, seats: int) -> str:
    total = plan_price(plan) * seats
    return f"{plan} x{seats}: ${total:,.2f}"
# billing/invoice.py (not part of the diff)
from billing.prices import plan_price


def invoice_line(plan: str, seats: int) -> str:
    total = plan_price(plan) * seats
    return f"{plan} x{seats}: ${total:,.2f}"

Ruff with the same rule sets passes both files, and so does mypy --strict. plan_price returns an int before and after the change, so as far as the tools can tell nothing is wrong. The invoice for one starter seat now reads starter x1: $2,900.00.

Intent and business rules

No rule knows what the ticket asked for, so code that cleanly does the wrong thing passes. The same goes for business rules like "refunds above a set amount need a second approval": unless someone writes a custom check, that rule lives in a spec or in someone's head.

Security that depends on context

OWASP lists "authentication problems, access control issues, insecure use of cryptography" among the vulnerabilities that are "very difficult to find automatically," and notes that scanners "frequently can't find configuration issues, since they are not represented in the code." To a pattern matcher, a route that forgets the permission check looks fine, even when every other route in the file runs it.

False positives

Rules also fire on code that is fine. go vet's documentation says it "uses heuristics that do not guarantee all reports are genuine problems," and the Clang Static Analyzer's says it "can falsely flag bugs in a program where the code behaves correctly." The S608 finding above matches the shape of a SQL string, so it fires whether or not customer_id can come from a user. OWASP lists "high numbers of false positives" as a weakness of security scanners.

Noise costs more than the minutes spent dismissing it. The Google chapter reports that "developers ignore compiler warnings," and it calls an issue an "effective false positive" if developers took no positive action after seeing it. Google's criteria for checks shown in code review include fewer than 10% effective false positives.

What AI code review adds

An AI reviewer starts from the pull request. It gathers the diff, the full files it touches, the code that calls the changed code and any rule files in the repository, often with the linked ticket. Then it comments inline on the lines it thinks are wrong, usually with a suggested fix. Because it reads the caller as well as the diff, the cents change is the kind of bug it is built to catch. A review comment might say:

plan_price now returns cents, but invoice_line in billing/invoice.py still formats the result as dollars, so one starter seat prints as $2,900.00. Divide by 100 there, or give cents their own type so the type checker catches the next caller.

The same context covers bugs that look fine line by line: a renamed field that a background job in another folder still reads, a catch block that logs a failed payment and returns success, a route that skips the membership check its neighbors run, a test that mocks the function it is meant to check. An AI reviewer also applies rules you write in plain English, such as "store money in integer cents."

Code written by AI coding agents gets review findings too. Bug Bench is cubic's public benchmark. It counts a cubic review finding on AI-written code as a bug when a developer upvoted it or an AI coding agent addressed it, and ranks models by relative bug rate: counted bugs per AI-attributed line, compared with the rate across all AI-attributed lines. Relative bug rates for the seven ranked models run from 0.65 to 1.50, where 1.00 is average, so every ranked model's code produced counted bugs. The benchmark is observational: teams choose their models, so a rate reflects the code each model was used for as well as the model.

Where AI code review is weaker

AI review is not deterministic. Run it twice on the same pull request and the results can vary, which makes it a poor merge blocker. Models also write plausible comments that are wrong, and a team that learns to skim a noisy reviewer misses the comment that mattered.

Most AI reviewers read code without running it, so a clean review doesn't prove the code works. Tests still own that question. Each review also takes minutes and model inference, where a linter takes seconds of CPU.

The reviewer only knows what it can read. A table's size in production, or a partner still calling an old endpoint, is invisible unless someone wrote it down where the reviewer can see it. Whether the change should exist at all, and which design trade-off to accept, stay with the people on your team.

Static code analysis vs AI code review compared


Static code analysis

AI code review

What it checks

Code against fixed rules: syntax, types, known bug and security patterns, style

The change in context: the diff, related files, your written rules, often the ticket

How it decides

Rules over syntax trees, types and data flow

A language model reading the code

Determinism

Same code and config, same findings

Findings and wording can vary between runs

False positives

Common in heuristic and security rules; tuned by disabling rules or suppressing lines

Plausible but wrong comments; good tools check findings against the code and learn from replies

Setup

A tool per language and a config file, wired into the editor, pre-commit and CI

An app on your Git host, plus rules in plain English

Where it runs

Editor, pre-commit hook, CI

The pull request; many tools also run in a terminal before you push

Speed

Seconds for linters; deep analyzers take longer

Minutes

Cost per run

CPU time; the common tools are open source

Model inference on every review, paid per seat or by usage

Safe to block merges

Yes, for rules with few false positives

Better as comments that a person weighs

Static code analysis tools

Open-source tools by language

All of these static analysis tools are open source. For each language you use, start with a linter, add a type checker if the language has optional types, and add a security linter where one exists.

Language

Tool

What it checks

JavaScript, TypeScript

ESLint

Patterns in JavaScript code, to make it consistent and avoid bugs

TypeScript

tsc in strict mode

The full family of strict type checks

Python

Ruff

Lint rules (over 900 built in) and formatting, written in Rust

Python

Pylint

Errors, coding standards and code smells, inferring values when code isn't fully typed

Python

mypy

Types, from your type hints

Python

Bandit

Common security issues, found by plugins that run on each file's AST

Go

go vet

Suspicious constructs, such as Printf calls whose arguments don't match the format string

Go

Staticcheck

Bugs, performance issues, simplifications and style, in 150+ checks

Rust

Clippy

Common mistakes, in over 800 lints grouped into categories

Java

Error Prone

Common mistakes, checked inside the compiler as you build

Java

SpotBugs

More than 400 bug patterns in compiled Java; a fork of the abandoned FindBugs

Java, Apex and 16 more

PMD

Flaws like unused variables and empty catch blocks, with 400+ rules and a copy-paste detector

Java

Checkstyle

Coding standards: naming, imports, formatting, Javadoc and class design

Ruby

RuboCop

The community Ruby Style Guide, as a linter and formatter

C, C++, Objective-C

Clang Static Analyzer

Deeper bugs of the kind usually found by testing, at a slower speed than compiling

C++

clang-tidy

Style violations, interface misuse and bugs that static analysis can deduce

Multi-language tools: SonarQube, Semgrep, CodeQL and Snyk Code

Most tools above cover one language. These four scan many languages from one setup, and each has a free option.

Tool

What it does

Free option

SonarQube

Bugs, vulnerabilities, security hotspots, code smells and architecture issues in 40+ languages. It runs as SonarQube Cloud (SaaS) or as self-managed SonarQube Server, with an IDE plugin

Community Build is free and self-managed, and analyzes only the main branch. The SonarQube Cloud Free plan also analyzes pull requests that target the main branch (feature comparison)

Semgrep

Security issues, style violations and bugs, using YAML rules that combine pattern matching and data flow analysis. You can use rules from the Semgrep Registry or write your own

The Community Edition engine is open source under LGPL 2.1. Semgrep Code, its extension, is proprietary

CodeQL

GitHub's analysis engine builds a database from your code, runs queries written in QL against it and shows the results as code scanning alerts. It supports C/C++, C#, Go, Java, Kotlin, JavaScript, TypeScript, Python, Ruby, Rust, Swift and GitHub Actions workflows

Free for public repositories on GitHub.com. Private repositories need GitHub Code Security, bought on a GitHub Team or Enterprise plan

Snyk Code

Security vulnerabilities (SAST), showing the data flow of an issue from source to sink. It runs in the IDE, the CLI, the Snyk web UI and pull request checks

The Free plan includes 100 Snyk Code tests a month

For how these compare with human review and AI review tools, see code review tools compared.

How to run static analysis and AI review together

Put each check where it runs cheapest and where its author can still act on it.

  1. In the editor, run the linter and type checker as you type. Google's chapter notes that analyses in an IDE need to be fast, "typically less than 1 second and ideally less than 100 ms."

  2. In a pre-commit hook, run the fast checks again. Git aborts the commit when the hook exits non-zero, but anyone can skip it with --no-verify (githooks), so CI has to run the same checks.

  3. In CI, run all of it: linters, type checks, security rules and tests. Make those jobs required status checks so a pull request can't merge into a protected branch until they pass.

  4. On the pull request, let AI review comment on what rules can't see, and have a person approve the intent and the design.

Block merges only on deterministic checks. In its most used languages, Google enables a compiler check only as an error that breaks the build, and only if it would "never stop the build for correct code"; checks that can't meet that bar are suppressed or shown in code review. AI review findings belong in that second group, as comments a person can accept or dismiss. For a rollout plan and the numbers that show whether it's working, read automated code review: what to automate, what to keep human.

Move repeat findings earlier. When AI review keeps catching the same mistake, turn it into a type or a lint rule so the editor catches it next time. Python's typing.NewType creates distinct types that a type checker keeps apart, so separate types for cents and dollars let mypy catch the billing mix-up:

from typing import NewType

Cents = NewType("Cents", int)
Dollars = NewType("Dollars", float)


def plan_price(plan: str) -> Cents:
    return Cents({"starter": 2900, "team": 9900}[plan])


def format_dollars(amount: Dollars) -> str:
    return f"${amount:,.2f}"


label = format_dollars(plan_price("starter"))
from typing import NewType

Cents = NewType("Cents", int)
Dollars = NewType("Dollars", float)


def plan_price(plan: str) -> Cents:
    return Cents({"starter": 2900, "team": 9900}[plan])


def format_dollars(amount: Dollars) -> str:
    return f"${amount:,.2f}"


label = format_dollars(plan_price("starter"))
from typing import NewType

Cents = NewType("Cents", int)
Dollars = NewType("Dollars", float)


def plan_price(plan: str) -> Cents:
    return Cents({"starter": 2900, "team": 9900}[plan])


def format_dollars(amount: Dollars) -> str:
    return f"${amount:,.2f}"


label = format_dollars(plan_price("starter"))
$ mypy money.py
money.py:15: error: Argument 1 to "format_dollars" has incompatible type "Cents"; expected "Dollars"  [arg-type]
Found 1 error in 1 file (checked 1 source

$ mypy money.py
money.py:15: error: Argument 1 to "format_dollars" has incompatible type "Cents"; expected "Dollars"  [arg-type]
Found 1 error in 1 file (checked 1 source

$ mypy money.py
money.py:15: error: Argument 1 to "format_dollars" has incompatible type "Cents"; expected "Dollars"  [arg-type]
Found 1 error in 1 file (checked 1 source

Rules that no pattern can express go the other way, into plain language for the AI reviewer. The docs for cubic's custom review rules, for example, recommend focusing them "on checks that linters do not cover, such as business logic and team-specific conventions" (custom rules).

FAQ

Can AI do static code analysis?

Most AI reviewers analyze code without executing it, which fits the definition of static analysis. They don't apply a fixed rule set, though, and the same code can get different results, so they don't replace a linter. A useful middle ground is asking a coding agent to write a custom lint rule for a mistake your reviews keep finding: Pylint accepts plugins for your own checks and PMD accepts rules written as XPath queries, while Ruff runs only its built-in rules.

What is the difference between static and dynamic code analysis?

Static analysis reads the source, and dynamic analysis watches the program run. AddressSanitizer is a typical dynamic tool: it adds compiler instrumentation and a run-time library, catches out-of-bounds accesses and use-after-free bugs while your tests run, and slows the program down about 2x. Static analysis checks code whether or not a test reaches it, while dynamic analysis only sees the paths that ran but reports what really happened on them.

Is static code analysis the same as SAST?

SAST, static application security testing, is the security-focused part of static analysis. Bandit, the S rules in Ruff, Snyk Code and CodeQL's security queries are all SAST checks. A type checker or a style linter is static analysis too, but its rules are about correctness and consistency, so it isn't usually called SAST.

Is SonarQube a static code analysis tool?

Yes. SonarQube's documentation describes its free Community Build as an automated code review and static analysis tool that checks code against rules for maintainability, reliability and security. Showing quality gate status on pull requests, and blocking a merge when the gate fails, needs SonarQube Server or a SonarQube Cloud Team or Enterprise plan.

How do you reduce false positives from static analysis?

Turn off rules your team keeps dismissing, and silence single findings in the code where a rule is wrong for that line: # noqa: E711 for Ruff, // eslint-disable-next-line no-alert for ESLint, or # type: ignore[union-attr] for mypy, which silences only that error code. Report new warnings first. Google's analysis team focuses on newly introduced warnings and warnings on modified lines, where the developer seeing them has the most context.

Table of contents