Skip to content

Using Pylint to Enforce Standards

  • naming conventions
  • missing docstrings
  • too many branches/arguments
  • potential errors
bash
pylint your_package
toml
tool.pylint."MESSAGES CONTROL"
disable = ["C0114", "C0115", "C0116"]
 
[tool.pylint.format]
max-line-length = 88
  • C convention
  • W warning
  • E error
  • R refactor

Don’t enable every rule at once in a legacy project.

Adopt gradually.

Every measurement on this page — and on the other five tools in this phase — comes from running the tool against this deliberately flawed file:

sample.py
import os
import subprocess
import hashlib
 
 
def process(items, user_input, flag = False):
    unused_var = 42
    result=[]
    for i in items:
        if i > 0:
            if flag:
                if i % 2 == 0:
                    result.append(i*2)
                else:
                    result.append(i)
            else:
                result.append(i)
    password = "hunter2"
    h = hashlib.md5(password.encode()).hexdigest()
    os.system("echo " + user_input)
    subprocess.call("ls " + user_input, shell=True)
    return result
 
 
def add(a: int, b: int) -> int:
    return a + b
 
 
x = add("1", 2)
toolwhat it reported on sample.py
flake85 style and dead-code issues. No security findings.
pylint4 issues, score 8.10/10
mypy1 type error, which neither linter saw
bandit5 security issues, 3 of them HIGH
radoncomplexity A (5), maintainability A (56.30)

The headline is that no tool subsumes another. flake8 read the whole file and reported nothing about the shell injection on line 20. bandit read the same file and said nothing about the type error on line 29. Running one and concluding the code is clean is the mistake this phase exists to prevent.

sketch Six tools, one file, almost no overlap p5.js
Each tool was run against the same 29-line file. Click a tool to see what it found and, more importantly, what it did not.
pylint sample.py
sample.py:1:0:  C0114: Missing module docstring (missing-module-docstring)
sample.py:6:0:  C0116: Missing function or method docstring (missing-function-docstring)
sample.py:19:4: W0612: Unused variable 'h' (unused-variable)
sample.py:25:0: C0116: Missing function or method docstring (missing-function-docstring)
 
Your code has been rated at 8.10/10

The message categories are worth learning, because they decide what you act on:

prefixmeaningact on it?
Eerror — likely a bugalways
Wwarning — suspicioususually
Cconvention — styleteam choice
Rrefactor — structural smelljudgement
Ffatal — pylint could not proceedalways

Three of the four findings above are C docstring conventions. The score of 8.10/10 is mostly a comment on documentation, not on the shell injection two lines below.

The finding that explains how linters differ

Section titled “The finding that explains how linters differ”

flake8 reported unused_var on line 7. Pylint did not — and forcing the check made no difference:

pylint --disable=all --enable=W0612 sample.py
sample.py:19:4: W0612: Unused variable 'h' (unused-variable)

Only h. The cause is a default setting:

pylint's default
dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_"

^unused_ matches the name, so pylint treats it as a deliberate discard. Renaming proves it:

after renaming unused_var to spare_value
sample2.py:7:4:  W0612: Unused variable 'spare_value' (unused-variable)
sample2.py:19:4: W0612: Unused variable 'h' (unused-variable)
diagram Diagram mermaid

Configuring it so people do not disable it

Section titled “Configuring it so people do not disable it”

Pylint’s defaults are stricter than most teams want, and the usual failure mode is a blanket disable. Configure it deliberately instead:

pyproject.toml
[tool.pylint.main]
ignore-paths = ["migrations/", ".venv/"]
 
[tool.pylint."messages control"]
disable = [
  "missing-module-docstring",
  "missing-function-docstring",
]
 
[tool.pylint.format]
max-line-length = 88          # match black

For a targeted exception, disable by name and scope it as narrowly as possible:

scoped.py
def handler(request, context):  # pylint: disable=unused-argument
    return "ok"

# pylint: disable=unused-argument on the signature covers that function only. A file-level disable at the top covers everything below it, which is almost never what you meant a week later.

flake8pylint
speedfastnoticeably slower — it infers types
defaultspermissivestrict
scopestyle plus dead codestyle, structure, some real bugs
scorenone0-10, useful as a trend

Many teams run both: flake8 in a pre-commit hook where speed matters, pylint in CI where thoroughness does. Neither one looks for the security issues in this file.

pch.quizTag pch.quizDefaultTitle
  1. flake8 reported an unused variable named unused_var; pylint did not, even with W0612 explicitly enabled. Why?

    pch.quizShowAnswer

    B — the name matches pylint's default dummy-variables-rgx, which includes ^unused_, so it is treated as a deliberate discard — Renaming it to spare_value made pylint report W0612 immediately. It is a configured convention for saying the discard is intentional, not a gap in the tool.

  2. Pylint rated the sample 8.10/10. What does that number mostly reflect here?

    pch.quizShowAnswer

    B — mostly missing docstrings; three of the four findings were C convention messages, and the shell injection was not among them — The score is a useful trend line, not a safety verdict. Bandit found three HIGH severity issues in the same file that pylint never looked for.

  3. What is the right way to silence a pylint message you have judged acceptable?

    pch.quizShowAnswer

    B — add a scoped comment naming the message, such as # pylint: disable=unused-argument on that line or function — Disable by name and at the narrowest scope. A file-level disable at the top covers everything below it, which is rarely what was intended later.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading