Skip to main content
Guides

Python Static Code Analysis: 5 Tools Tested on One Buggy File

By Mithun··11 min read
Quick Answer

Static code analysis inspects Python without running it to find bugs, style issues, security holes and type errors. No single tool does everything: a linter (Ruff or Pylint) catches unused imports, undefined names and bad patterns; a security scanner (Bandit) flags things like eval and SQL injection; a type checker (mypy) catches type bugs the others can't. I ran all five on one buggy file below. The practical baseline for most projects is Ruff + mypy — both free to try in your browser (Ruff linter, type checker).

"Static analysis" sounds heavy, but it just means checking your code by reading it, not running it — the way an editor underlines a mistake before you hit run. For Python it's the cheapest way to catch a whole class of bugs, security issues and style problems automatically.

The confusing part is that there are half a dozen tools that all claim to "analyze" your Python, and they don't overlap the way you'd expect. So I wrote one deliberately awful 30-line file with nine planted problems and ran Ruff, Pylint, Flake8, Bandit and mypy against it. Here's exactly what each one caught — and, from that, which you actually need.

The two families of static analysis

Before the test, the one distinction that clears up all the confusion: Python static-analysis tools split into groups that do genuinely different jobs, which is why you end up running more than one.

  • Linters — catch likely bugs and bad patterns (unused imports, undefined names, shadowed builtins, mutable default arguments) and enforce style. Ruff, Pylint, and Flake8 live here.
  • Security scanners — look specifically for insecure code (eval, subprocess with shell=True, hardcoded passwords, SQL built by string concatenation). Bandit is the standard.
  • Type checkers — read your type hints and prove that values, arguments and return types actually line up. mypy (and pyright, ty) do this and *only* this.

None of these subsumes the others completely — as the test makes obvious.

The test file

Thirty lines, nine intentional problems spanning all three families — unused imports, an undefined name, an unused variable, a mutable default argument, eval, a hardcoded password, string-built SQL, a shadowed builtin, and a function whose return type doesn't match its annotation:

import os
import hashlib
PASSWORD = "hunter2"
def get_user(id):
query = "SELECT * FROM users WHERE id = " + id
return run(query)
def add_tags(tag, tags=[]):
tags.append(tag)
return tags
def risky(cmd):
return eval(cmd)
def total(items):
result = 0
for i in items:
result = result + i
unused = 42
return result
def greet(name: str) -> int:
return "hi " + name
userstore.py — the file every tool below analyzed

Ruff — the fast all-rounder

Ruff is a linter written in Rust that's become the default because it's astonishingly fast and folds in the rules of many older tools. Out of the box it runs a core set (pyflakes F + pycodestyle E) and caught four issues:

userstore.py:1:8: F401 `os` imported but unused
userstore.py:2:8: F401 `hashlib` imported but unused
userstore.py:9:12: F821 Undefined name `run`
userstore.py:25:5: F841 Local variable `unused` is assigned to but never used
Found 4 errors.
$ ruff check userstore.py (default rules)

But Ruff's real power is that it re-implements rules from bugbear, flake8-bandit and more — so you can turn on security and pattern checks the default set skips. With a broader selection it caught eight, including the security issues:

userstore.py:1:8: F401 `os` imported but unused
userstore.py:2:8: F401 `hashlib` imported but unused
userstore.py:4:12: S105 Possible hardcoded password assigned to: "PASSWORD"
userstore.py:8:13: S608 Possible SQL injection vector through string-based query construction
userstore.py:9:12: F821 Undefined name `run`
userstore.py:12:24: B006 Do not use mutable data structures for argument defaults
userstore.py:18:12: S307 Use of possibly insecure function; consider using `ast.literal_eval`
userstore.py:25:5: F841 Local variable `unused` is assigned to but never used
Found 8 errors.
$ ruff check --select E,F,B,S,PL userstore.py

You can run exactly this in the browser — our Ruff linter online runs the official Ruff WebAssembly build, and the Ruff Playground lets you edit the rule selection live and watch diagnostics update. Nothing is uploaded.

Pylint — the deepest, most opinionated linter

pip install pylint. Pylint digs deeper into conventions and design than Ruff's defaults, and it's the only tool in the test that flagged the shadowed builtin (id) and the missing docstrings. It also scores your file — this one earned a memorable 1.05/10:

userstore.py:1:0: C0114: Missing module docstring (missing-module-docstring)
userstore.py:7:0: C0116: Missing function or method docstring
userstore.py:7:13: W0622: Redefining built-in 'id' (redefined-builtin)
userstore.py:9:11: E0602: Undefined variable 'run' (undefined-variable)
userstore.py:12:0: W0102: Dangerous default value [] as argument (dangerous-default-value)
userstore.py:18:11: W0123: Use of eval (eval-used)
userstore.py:25:4: W0612: Unused variable 'unused' (unused-variable)
userstore.py:1:0: W0611: Unused import os (unused-import)
Your code has been rated at 1.05/10
$ pylint userstore.py (trimmed)

The trade-off is speed and noise: Pylint is much slower than Ruff and far chattier (it will nag about docstrings and naming), which is why many teams now run Ruff for everyday linting and reserve Pylint for deeper reviews. See the head-to-head in Ruff vs Pylint.

Bandit — the security scanner

pip install bandit. Bandit does one job — find security problems — and reports each with a severity, a confidence, and a CWE identifier. It caught the three genuine vulnerabilities in the file:

>> [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2'
Severity: Low Confidence: Medium CWE: CWE-259 at userstore.py:4
>> [B608:hardcoded_sql_expressions] Possible SQL injection vector...
Severity: Medium Confidence: Low CWE: CWE-89 at userstore.py:8
>> [B307:blacklist] Use of possibly insecure function - consider ast.literal_eval
Severity: Medium Confidence: High CWE: CWE-78 at userstore.py:18
$ bandit userstore.py (trimmed)

Ruff's S rules (shown above) re-implement much of Bandit, so if you already run Ruff with --select S you may not need Bandit separately — but Bandit's dedicated output (severity/confidence/CWE) is still handy for security-focused reviews and CI gates.

mypy — the type checker (catches what no linter can)

pip install mypy. This is the one that matters most, because it finds a category of bug none of the linters can see: type mismatches. In the file, greet is annotated to return an int but actually returns a string. Every linter above ignored it; mypy caught it immediately:

userstore.py:30: error: Incompatible return value type (got "str", expected "int") [return-value]
Found 1 error in 1 file (checked 1 source file)
$ mypy userstore.py

That's the whole argument for adding a type checker: linters check *how* your code is written, but a type checker checks whether the values actually fit together — wrong argument types, None where a value is required, return types that don't match. You can run mypy in the browser with our Python type checker (mypy compiled to WebAssembly), and there's a fuller comparison of the options in ty vs mypy vs pyright.

What each tool caught — the matrix

Lined up side by side, the division of labor is obvious. "Ruff (+S,B)" is Ruff with the security and bugbear rules enabled.

Planted issueRuff (default)Ruff (+S,B)PylintBanditmypy
Unused import
Undefined name run
Unused variable
Mutable default arg
eval use
Hardcoded password
SQL injection
Redefining builtin id
Missing docstrings
Wrong return type

Two things jump out: broadly-configured Ruff covers most of the ground (lint + security + patterns) in one fast pass, and the type bug is invisible to every linter — only mypy sees it.

What to actually run

You don't need all five. A sensible, low-friction setup for almost any Python project:

  1. Ruff for linting — fast enough to run on every save and in CI, and with --select it absorbs Flake8, much of Bandit, and bugbear. This is your everyday workhorse. Try it free at Ruff linter online.
  2. mypy for type checking — it catches the bugs linters structurally cannot. Add type hints and run it in CI; check snippets in the browser with our type checker.
  3. Bandit if security matters — for apps handling untrusted input or secrets, its dedicated severity/CWE output is worth the extra step (or enable Ruff's S rules).
  4. Pylint for periodic deep reviews — too slow and chatty for every commit for many teams, but excellent when you want the most thorough convention and design feedback.

Static analysis finds bugs before runtime, but it's not a substitute for tests — and it can't reason about values it never sees. Pair Ruff + mypy for the biggest payoff-per-minute, then add tests for behavior. Related: Ruff vs Flake8 and Ruff vs Pylint.

Lint your Python online — free, nothing uploaded

Paste your code into our Ruff linter online (official Ruff WebAssembly build) to catch unused imports, undefined names, security issues and more in your browser.

Open the Ruff Linter

Free tools mentioned here

Related guides

Frequently asked questions

What is static code analysis in Python?

Static code analysis inspects Python source without executing it, to find bugs, style problems, security vulnerabilities and type errors early. Tools read your code (and its type hints) and report issues like unused imports, undefined names, insecure eval calls, or a function returning the wrong type. It's the cheapest way to catch a whole class of mistakes automatically, before tests or runtime.

What is the best static analysis tool for Python?

There's no single best — they do different jobs. For everyday linting, Ruff is the fast default and, with security rules enabled, covers most ground in one pass. mypy is essential for catching type errors no linter can see. Bandit specializes in security, and Pylint gives the deepest convention and design feedback. A practical baseline is Ruff plus mypy, adding Bandit where security matters.

Is a linter the same as static analysis?

A linter is one kind of static analysis. Static analysis is the broad category of checking code without running it; linters (Ruff, Pylint, Flake8) are the subset focused on likely bugs and style. Security scanners (Bandit) and type checkers (mypy) are also static analysis but target different problems — in testing, only mypy caught a wrong return type, and only Bandit/Ruff flagged the security issues.

Does Ruff replace Flake8, Bandit and Pylint?

Ruff replaces Flake8 outright and re-implements many Bandit and bugbear rules, so with '--select S,B' it covers much of what those tools do in a single fast pass. It overlaps with Pylint on common checks but doesn't fully replace Pylint's deepest design and convention analysis, and it is not a type checker — you still need mypy (or pyright) for type checking.

How do I check Python code for errors without running it?

Use static analysis tools. Run a linter like Ruff (ruff check file.py) to catch unused imports, undefined names and bad patterns, and a type checker like mypy (mypy file.py) to catch type mismatches. You can do both in the browser with no install using the online Ruff linter and the online Python type checker, which run the real tools compiled to WebAssembly.

Keep reading