mrkeyoor.com_
Thu 06 Aug 10:58 UTC
PyPICLI & Toolingupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The CLI and config surface barely change, but the set of emitted messages is the real contract and it shifts every release: 4.0 rebound invalid-name to variable-rgx for reassigned module constants, changed continue-in-finally from E0116 to W0136, and removed suggestion-mode. New checks and inference improvements mean a pinned version is close to mandatory in CI.
Docs5/5One of the best-documented tools in Python. Every message has its own page with a bad example, a good example, and the rationale, plus a full options reference, a plugin authoring guide, and a per-release what's-new page written for humans rather than generated from commit titles.
Maintenance5/5Pushed 3 August 2026, six 4.x releases since October 2025, and PyPI classifies it Development Status 6 Mature. The 1,008 open issues (1,080 counting PRs) look alarming but are mostly individual false-positive reports on a tool with hundreds of checks, and recent releases are almost entirely those reports being closed.
Ecosystem5/5Around 16.1 million downloads a week, editor integrations everywhere, and a plugin ecosystem covering Django, pydantic, Odoo, Celery, and more, usually findable by searching PyPI for pylint plus the library name. Ruff has taken share for everyday linting, but pylint remains the reference for depth.

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
Skip it if

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 error

An 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 checks

This 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-name

A 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 mypackage

The 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}' mypackage

json2 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 mypackage

Without 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 mypackage

pyreverse 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

PackageRegistryPick it when
ruffPyPIYou 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.
mypyPyPIYour 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.
flake8PyPIYou want a small, quiet, plugin-driven checker that stays close to PEP 8 and does not have opinions about your class design.