pylint
pylint reads your Python without running it and tells you what is wrong with it, from genuine bugs down to naming style. What separates it from every other Python linter is inference: through its companion library astroid it builds a model of what each name actually refers to, so it can follow an aliased import, work out that a variable is a str at this point in the function, and report that the method you called does not exist on that object. Type hints help but are not required, which is why it still finds real errors in untyped legacy code. The cost is speed and noise: it is much slower than AST-pattern linters, and out of the box it emits convention and refactoring opinions alongside errors. It ships two extra tools, pyreverse for UML class and package diagrams and symilar for duplicate-code detection, and it is licensed GPLv2 rather than the usual permissive licence.
Still the most thorough Python linter there is, and the inference engine finds bugs nothing else in the ecosystem will. In 2026 the sane setup is ruff for the fast pass and pylint scoped to what only it can do, rather than pylint as your only linter with everything enabled.
Use it if
- Your codebase is largely untyped and mypy has little to work with; pylint's inference finds missing attributes, wrong argument counts, and used-before-assignment without any annotations
- You want checks no fast linter implements: no-member on inferred objects, cyclic-import across the whole package, duplicate-code, and the too-many-branches family of complexity limits
- You need to encode a house rule as a real check, because writing a pylint plugin is a small Python class and the framework for messages, options, and tests is already there
- You are chasing a specific class of bug rather than style, and want to run just the error category with --errors-only as an extra CI gate on top of a faster linter
- You want the diagrams: pyreverse turns a package into class and package graphs, which is faster than drawing them by hand for a codebase you inherited
- You care about wall-clock time in CI or on save. Its own README quotes a user calling it painfully slow and says inference is why; whole-repo runs on a large project are minutes, not the sub-second feedback ruff gives you
- You already run ruff and mypy and are looking for more coverage. Ruff has reimplemented a large slice of pylint's rules under its PL prefix and fixes many of them automatically, and mypy covers the type-shaped errors, so what is left is a narrow band of inference checks you may not need
- Nobody on the team wants to own the config. The default profile turns on convention and refactor messages, so a legacy codebase produces a wall of invalid-name, missing-docstring, and too-many-arguments; the maintainers' own advice is to start with --errors-only and re-enable categories later, which means an ongoing config negotiation
- You depend on frameworks that generate attributes at runtime. Django models, SQLAlchemy declarative classes, and pydantic all confuse inference and produce no-member floods until you install the matching pylint plugin, and plugins do not exist for every library
- You need automatic fixes. pylint reports and scores; it does not rewrite your code, so every finding is manual work
- The GPLv2 licence is a problem for you. That is fine for running the CLI in CI, but it constrains embedding pylint in a distributed proprietary product or shipping a plugin that imports it
- You cannot absorb a rule change at upgrade time. Version 4.0 changed how invalid-name treats reassigned module-level constants, moved continue-in-finally from E0116 to W0136, and dropped the suggestion-mode option, all of which can turn a green pipeline red
Setup reality
pip install pylint pulls astroid, isort, mccabe, platformdirs, tomlkit, and dill, plus colorama on Windows, and needs Python 3.10 or newer since version 4.0. The install is easy; making it useful is not. pylint has to run inside the same environment as your project's dependencies, because astroid resolves imports from the interpreter it is running under, so a pylint installed with pipx or a global tool manager will report import-error for every third-party package you use. That is also why the pre-commit hook usually needs additional_dependencies listing your real dependencies, or a local hook with language: system. Configuration lives in pyproject.toml under sections such as [tool.pylint.'MESSAGES CONTROL'], and pylint searches an eleven-step chain of possible config file locations, which makes it easy to have a stray .pylintrc quietly winning; run --generate-toml-config to see the settings actually in effect. Parallelism through -j 0 helps, but the docs warn that splitting files across processes degrades inference, so results can change with the file set. Budget a real afternoon for the first adoption pass on any codebase that has not been linted before.
Patterns
Lint a package and understand what the exit code meansrun-and-read-exit-code
pylint mypackage
pylint src/app.py src/util.py
echo $? # bit-encoded: 1 fatal, 2 error, 4 warning, 8 refactor, 16 convention, 32 usage errorAn exit code of 20 means at least one warning (4) and at least one convention message (16). Any message at all makes the exit code nonzero, so a plain pylint call in CI fails on a missing docstring exactly as hard as on a real error. Pass a package directory rather than a glob so inference sees the whole package.
Introduce pylint to a codebase that has never been lintedadopt-on-legacy-code
# step 1: bugs only
pylint --errors-only mypackage
# step 2: add warnings, keep style out of it
pylint --disable=C,R mypackage
# step 3: turn categories back on as you clean up
pylint --disable=C0114,C0115,C0116 mypackage # the three missing-docstring checksThis is the sequence the maintainers recommend in the README. Turning everything on at once on a legacy repo produces thousands of messages, the team stops reading the output, and the tool gets removed a month later.
Keep configuration in pyproject.tomlconfigure-in-pyproject
[tool.pylint.main]
jobs = 0
ignore-paths = ["^migrations/.*$", "^tests/fixtures/.*$"]
load-plugins = ["pylint.extensions.no_self_use"]
[tool.pylint."MESSAGES CONTROL"]
disable = ["missing-module-docstring", "too-few-public-methods"]
[tool.pylint.design]
max-args = 8
[tool.pylint.basic]
good-names = ["i", "j", "k", "db", "pk", "_"]Section names inside pyproject.toml must be prefixed with tool.pylint, and MESSAGES CONTROL keeps its spaces and needs quoting. pylint checks eleven locations for config in a fixed order, so a leftover .pylintrc in the working directory beats your pyproject; run pylint --generate-toml-config to dump the settings actually in effect.
Silence a check for one line, one block, or one filedisable-messages-locally
import os # pylint: disable=unused-import
def handler(event, context): # pylint: disable=unused-argument
...
def legacy():
# pylint: disable=too-many-locals
...
# pylint: enable=too-many-locals
# at the top of a file, applies to the whole module:
# pylint: disable=invalid-nameA disable comment on a line applies to that line; one on its own line applies from there to the end of the enclosing block, which is why an unpaired disable inside a function can leak further than you meant. Use the message name rather than the code so the next reader knows what you turned off, and turn on useless-suppression to find comments that no longer suppress anything.
Fail the build on the right things onlyci-gating
# fail only if an error-category message appears
pylint --disable=all --enable=E --fail-on=E mypackage
# fail below a score threshold instead of on any message
pylint --fail-under=9.5 mypackage
# report everything but never fail the job
pylint --exit-zero mypackage--fail-under uses the 0 to 10 score, which moves when you add files, so it drifts on a growing codebase. --fail-on is the sharper tool: it forces a nonzero exit for the listed categories or message names even if you are otherwise below the threshold.
Use more cores, and know what it costsparallel-runs
pylint -j 0 mypackage # 0 means autodetect CPU count
pylint -j 4 mypackageThe docs are explicit that pylint infers best when it sees all your code at once. Splitting files across processes, whether with -j or by sharding the file list yourself, can change which values it can infer, so results are not guaranteed identical to a serial run. duplicate-code in particular needs the whole set.
Emit JSON or a custom line format for other toolsmachine-readable-output
pylint --output-format=json2 mypackage > pylint.json
pylint --output-format=colorized mypackage
pylint --msg-template='{path}:{line}:{column}: {msg_id} ({symbol}) {msg}' mypackagejson2 is the current structured format and includes the messages plus run statistics and the score; the older json format is still accepted but flatter. Use msg-template when a CI annotation system wants a specific single-line shape.
Add framework plugins and optional built-in extensionsload-plugins
pip install pylint-django pylint-pydantic
pylint --load-plugins=pylint_django,pylint_pydantic mypackage
# built-in checkers that are off by default
pylint --load-plugins=pylint.extensions.mccabe,pylint.extensions.docparams mypackageWithout pylint-django, a Django model's objects manager and every reverse relation trigger no-member. The pylint.extensions namespace holds extra checkers the maintainers ship but keep disabled, including cyclomatic complexity and docstring parameter checking; list them in load-plugins in pyproject.toml so local runs and CI agree.
Write a plugin that enforces a house rulecustom-checker
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
class NoPrintChecker(BaseChecker):
name = "no-print"
msgs = {
"W9001": (
"Use the logger instead of print()",
"forbidden-print",
"print() writes to stdout and disappears from production logs.",
),
}
def visit_call(self, node: nodes.Call) -> None:
if isinstance(node.func, nodes.Name) and node.func.name == "print":
self.add_message("forbidden-print", node=node)
def register(linter: PyLinter) -> None:
linter.register_checker(NoPrintChecker(linter))Message ids in the 9000 range are reserved for third-party checkers, so you will not collide with a future built-in. The visit_ method name has to match the astroid node class in lowercase. The module must be importable and named in load-plugins, which means it needs to be on sys.path for CI too.
Run pylint from pre-commit without breaking importspre-commit-hook
repos:
- repo: local
hooks:
- id: pylint
name: pylint
entry: pylint
language: system
types: [python]
require_serial: true
args: ["--errors-only"]The upstream pre-commit hook installs pylint in its own isolated environment, so every third-party import becomes import-error unless you repeat your dependencies under additional_dependencies. A local hook with language: system reuses your project virtualenv instead. require_serial matters because pre-commit otherwise passes file subsets, which weakens inference.
Draw class and package diagrams with pyreversegenerate-diagrams
pyreverse -o png -p myproject mypackage
# writes classes_myproject.png and packages_myproject.png
pyreverse -o mmd -p myproject mypackage # mermaid, renders in a README
pyreverse -o dot -A -S -p myproject mypackagepyreverse ships with pylint, no extra install. PNG and SVG output need Graphviz on the machine; the mermaid and plantuml outputs are plain text and need nothing. In 4.0 the programmatic Run class stopped calling sys.exit() in its constructor, so scripted use is now Run(args).run().
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ruff | PyPI | You want fast feedback and automatic fixes over depth; it covers a lot of pylint's rules plus flake8, isort, and pyupgrade, without the inference engine. |
| mypy | PyPI | Your code is annotated and the bugs you want caught are type errors; a type checker will do that far better than inference over untyped code. |
| flake8 | PyPI | You want a small, quiet, plugin-driven checker that stays close to PEP 8 and does not have opinions about your class design. |