Using Pylint to Enforce Standards
What Pylint is good at
Section titled “What Pylint is good at”- naming conventions
- missing docstrings
- too many branches/arguments
- potential errors
Example run
Section titled “Example run”pylint your_packageTypical config (pyproject.toml)
Section titled “Typical config (pyproject.toml)”tool.pylint."MESSAGES CONTROL"
disable = ["C0114", "C0115", "C0116"]
[tool.pylint.format]
max-line-length = 88How to read Pylint output
Section titled “How to read Pylint output”CconventionWwarningEerrorRrefactor
Don’t enable every rule at once in a legacy project.
Adopt gradually.
One file, six tools
Section titled “One file, six tools”Every measurement on this page — and on the other five tools in this phase — comes from running the tool against this deliberately flawed file:
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)| tool | what it reported on sample.py |
|---|---|
| flake8 | 5 style and dead-code issues. No security findings. |
| pylint | 4 issues, score 8.10/10 |
| mypy | 1 type error, which neither linter saw |
| bandit | 5 security issues, 3 of them HIGH |
| radon | complexity 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.
Pylint is opinionated, and scores you
Section titled “Pylint is opinionated, and scores you”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/10The message categories are worth learning, because they decide what you act on:
| prefix | meaning | act on it? |
|---|---|---|
E | error — likely a bug | always |
W | warning — suspicious | usually |
C | convention — style | team choice |
R | refactor — structural smell | judgement |
F | fatal — pylint could not proceed | always |
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:
sample.py:19:4: W0612: Unused variable 'h' (unused-variable)Only h. The cause is a default setting:
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:
sample2.py:7:4: W0612: Unused variable 'spare_value' (unused-variable)
sample2.py:19:4: W0612: Unused variable 'h' (unused-variable) flowchart TD
V["an unused local variable"] --> N{"does the name match
dummy-variables-rgx?"}
N -->|"unused_x, _, dummy, ignored_x"| S["pylint stays silent
you meant to discard it"]
N -->|"anything else"| W["W0612 unused-variable"]
V --> F["flake8 reports F841 either way"]
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:
[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 blackFor a targeted exception, disable by name and scope it as narrowly as possible:
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.
Pylint against flake8
Section titled “Pylint against flake8”| flake8 | pylint | |
|---|---|---|
| speed | fast | noticeably slower — it infers types |
| defaults | permissive | strict |
| scope | style plus dead code | style, structure, some real bugs |
| score | none | 0-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.
Check yourself
Section titled “Check yourself”-
flake8 reported an unused variable named unused_var; pylint did not, even with W0612 explicitly enabled. Why?
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.
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.
-
Pylint rated the sample 8.10/10. What does that number mostly reflect here?
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.
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.
-
What is the right way to silence a pylint message you have judged acceptable?
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.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.coffeeCtapch.feedbackHeading
pch.feedbackSubheading