There is no single best Python linter, but there is a clear default: Ruff for most new projects, because it combines linting, import sorting, and formatting in one fast tool with rules ported from the classic Python ecosystem. Choose Pylint when you want the deepest inference-based checks, and Flake8 when you depend on specific community plugins.
That answer holds for the common case. Below is the reasoning behind it — what each tool actually inspects, where linters stop and type checkers begin, how to compare candidates on criteria rather than reputation, and a config you can paste into a real project today.
What does a Python linter actually check?
A linter parses your source into an abstract syntax tree and applies rules to that tree without executing the code. In Python that typically covers three overlapping families:
- Likely bugs. Unused imports and variables, undefined names, shadowed builtins, mutable default arguments, bare
exceptclauses, comparisons that can never be true. - Style and conventions. Naming, line length, import order, spacing — the PEP 8 surface. Much of this is better delegated to a formatter, which is why the two tools coexist.
- Modernization and idiom. Rewriting legacy syntax for newer Python versions, flagging patterns that have a clearer idiomatic form.
What a linter generally does not do is reason about types across your whole program. Detecting that you passed a str where an int was expected is the job of a type checker such as mypy or Pyright. If you are new to the category boundaries, what linting is and what it catches covers the fundamentals before you start comparing products.
Which Python linters are worth shortlisting?
Four mainstream open-source tools cover almost every real project. Each is a legitimate choice; the differences are in speed, depth, and how much configuration you inherit.
Ruff is a linter and formatter written in Rust by Astral. Its rule set re-implements checks originating in Pyflakes, pycodestyle, isort, pyupgrade, and a number of Flake8 plugins, exposed under familiar rule-code prefixes (F, E, I, UP, B). It runs ruff check for linting and ruff format for formatting, and it is configured in pyproject.toml. The Ruff project's headline claim is speed, and it is the reason most teams evaluate it first.
Flake8 is the long-standing wrapper that bundles Pyflakes (logical errors), pycodestyle (PEP 8 style), and McCabe (complexity). Its real strength is the plugin ecosystem accumulated over a decade — if your team relies on a niche plugin with no Ruff equivalent, Flake8 remains the practical answer. Note that it reads configuration from setup.cfg, tox.ini, or .flake8 rather than pyproject.toml.
Pylint goes deepest. It builds an inference model of your code through the astroid library, which lets it flag things a purely syntactic checker cannot — for example, calling a method that does not exist on an inferred class. That depth costs runtime and produces more findings out of the box, so Pylint rewards teams willing to curate a rule set.
Bandit is a different category: a security-focused scanner for common issue patterns such as use of assert in production paths, insecure hash choices, or subprocess calls with shell=True. It complements a linter rather than replacing one.
Tooling in this space moves quickly. Rule sets, defaults, and configuration keys change between releases, so confirm details against each project's current documentation before you commit a config to your repo.
How do the options compare on the criteria that matter?
| Criterion | Ruff | Flake8 | Pylint |
|---|---|---|---|
| Primary role | Linter + formatter + import sorting | Linter (wrapper over Pyflakes/pycodestyle/McCabe) | Linter with type inference |
| Detection depth | Broad rule catalog, largely syntactic | Logical errors + style, extendable by plugins | Deepest; infers types and attributes |
| Speed profile | Designed for speed; the usual reason teams switch | Moderate; scales with plugin count | Slowest of the three by design |
| Configuration | pyproject.toml, [tool.ruff] |
setup.cfg / tox.ini / .flake8 |
.pylintrc or [tool.pylint.*] |
| Autofix | Yes, for a large share of rules (--fix) |
No (plugins vary) | Limited |
| Plugin ecosystem | Rules ported into core; no third-party plugin API | Large, mature plugin ecosystem | Plugin API available |
| Best when | You want one fast tool for lint + format | You depend on a specific plugin | You want maximum static depth |
Read this as a shortlisting aid, not a verdict. The criteria that decide it for you are the ones your codebase actually stresses — CI wall-clock time on a large repo, a required plugin, or a class of bug you keep shipping.
What does a working Python lint config look like?
A minimal Ruff setup in pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B011"]
select opts into rule groups: E/F for pycodestyle and Pyflakes checks, I for import sorting, UP for modernization, B for likely-bug patterns. E501 (line length) is ignored here because the formatter already handles line width — a common and deliberate combination.
To run it on every commit via the pre-commit framework, in .pre-commit-config.yaml:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9 # pin to the latest release
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
If you keep Flake8 instead, its equivalent lives in setup.cfg:
[flake8]
max-line-length = 100
extend-ignore = E203
exclude = .venv,build,dist
E203 is commonly ignored because it conflicts with how Black-style formatters handle slicing whitespace.
How do you adopt a Python linter without a thousand-error wall?
- Pick one candidate and run it in report-only mode across the repo. Count findings by rule code, not in total — the distribution tells you what to do next.
- Adopt a formatter first. Running
ruff formator Black once removes most style findings in a single mechanical commit that no one needs to review line by line. See whether you need a code formatter for why this ordering matters. - Apply autofixes in a separate commit.
ruff check --fixhandles a large share of the remainder. Keeping it isolated from behavioural changes keeps the diff reviewable andgit blameusable. - Curate the rest. For each remaining high-count rule, decide explicitly: fix it, ignore it project-wide in config, or scope it with per-file ignores. Silence with a reason, never by deleting the tool.
- Gate it in CI once the repo is clean, then add a type checker separately if you want deeper guarantees.
The sequencing is the whole trick: format, autofix, curate, gate. Teams that gate first usually end up disabling the linter within a month.
Do you still need a formatter and a type checker?
Yes, in most cases — they are three distinct layers. The formatter makes style non-negotiable and unreviewable. The linter catches likely bugs and enforces conventions the formatter cannot express. The type checker verifies contracts across module boundaries. Ruff's format command covers the first layer for many teams, but neither Ruff nor Pylint replaces mypy or Pyright. The broader per-language decision framework is in how to choose a linter for your language.
FAQ
Is Ruff a drop-in replacement for Flake8?
For the common rule set, largely yes — Ruff implements equivalents of Pyflakes, pycodestyle, and many popular Flake8 plugin rules under the same codes. It is not a drop-in for every third-party plugin, since Ruff has no third-party plugin API. Check whether the specific plugins you rely on have a Ruff equivalent before migrating.
Can you run Ruff and Pylint together?
You can, and some teams do: Ruff for the fast pass on every save and commit, Pylint for a deeper scheduled or pre-merge run. The cost is two rule sets to keep from contradicting each other. Only take that on if Pylint's inference is catching things you genuinely care about.
Does a linter replace tests?
No. A linter reasons about code without running it, so it cannot tell you whether your logic is correct — only that certain patterns are suspicious or non-conforming. It reduces a class of avoidable defects before tests run; it does not shrink the test suite you need.
What about security issues in Python code?
Linters catch some security-adjacent patterns, but dedicated static application security testing tools go further with taint analysis and a security-specific rule catalog. Bandit is the common open-source starting point for Python; larger organizations often layer a commercial SAST scanner on top.
Should you use per-file ignores or inline suppressions?
Prefer per-file ignores in config for structural exceptions like generated code or test directories — they are visible and reviewable in one place. Use inline # noqa: RULE comments for genuine one-off exceptions, always with the specific rule code rather than a bare # noqa.
Picking a Python linter is less about finding the objectively best tool than about matching detection depth, speed, and configuration burden to what your codebase actually needs — then adopting it in an order that keeps the first pull request reviewable. When you are ready to weigh specific candidates side by side, compare the leading Python linters and formatters on Lintense — criteria-first comparisons on detection depth, speed, configuration experience, and cost — before you standardize your team on one.