The most common Python trap & how to find it
We had a near miss with a mutable default argument in one of my Python projects.
Mutable defaults are an old and well-documented problem in Python, arguably a language design mistake. Given its notoriety, I was running under the assumption that it's the kind of obvious Python bug that any linter would immediately pick up and flag. I were to explain to a 6-year-old what a "python linter" is, I'd probably say someting like "a tool that finds typical bugs, like mutable default function arguments".
Turns out I was being optimistic!
We caught the bug before it went live, but I resolved to find which static analysis tools actually check for this problem.
Test code
Here's the code I used for my tests:
def buggy_fn(arg: list[int] = []) -> list[int]: arg.append(42) return arg class BuggyClass: items: list[int] = [] def append(self, x: int) -> None: self.items.append(x)
There's two similar but different problems here:
-
buggy_fnincorrectly uses the same list instance for each execution of the function, -
BuggyClassincorrectly shares the same instance ofitemsacross all class instances.
Let's see what various linters have to say about this code.
Tests
First, flake8 7.2.0 which is what my project uses:
$ uvx flake8 sample.py $
Nothing by default, but there's a plug-in:
$ uvx --with flake8-bugbear flake8 sample.py sample.py:1:31: B006 Do not use mutable data structures for argument defaults. They are created during function definition time. All calls to the function reuse this one instance of that data structure, persisting changes between them.
Better! It flagged buggy_fn, but it still didn't flag BuggyClass.
Next, let's try pyright 1.1.400. Pyright is a type checker but also does some misc diagnostics.
$ uvx pyright sample.py 0 errors, 0 warnings, 0 informations
What if we tried more power?
$ echo '{"typeCheckingMode": "strict", "strict": ["*"]}' > pyrightconfig.json $ uvx pyright sample.py 0 errors, 0 warnings, 0 informations
Not much. How about mypy 1.15.0 then? Mypy is more strictly focused on types, but let's try:
$ uvx mypy --strict sample.py Success: no issues found in 1 source file
How about the classic pylint 3.3.7?
$ uvx pylint sample.py ************* Module sample sample.py:1:0: C0114: Missing module docstring (missing-module-docstring) sample.py:1:0: C0116: Missing function or method docstring (missing-function-docstring) sample.py:1:0: W0102: Dangerous default value [] as argument (dangerous-default-value) sample.py:6:0: C0115: Missing class docstring (missing-class-docstring) sample.py:9:4: C0116: Missing function or method docstring (missing-function-docstring) sample.py:6:0: R0903: Too few public methods (1/2) (too-few-public-methods) ------------------------------------------------------------------ Your code has been rated at 1.43/10 (previous run: 0.00/10, +1.43)
I never used Pylint on a production project but it actually found one of two issues!
Finally, let's look at ruff 0.11.10, our promising Rust-powered contestant.
$ uvx ruff check sample.py All checks passed!
Nothing on default settings, but let's enable some optional rules:
$ uvx ruff check --extend-select B,RUF sample.py sample.py:1:31: B006 Do not use mutable data structures for argument defaults | 1 | def buggy_fn(arg: list[int] = []) -> list[int]: | ^^ B006 2 | arg.append(42) 3 | return arg | = help: Replace with `None`; initialize within function sample.py:7:24: RUF012 Mutable class attributes should be annotated with `typing.ClassVar` | 6 | class BuggyClass: 7 | items: list[int] = [] | ^^ RUF012 8 | 9 | def append(self, x: int) -> None: | Found 2 errors. No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).
Okay, ruff was the only linter here that flagged both the function and the class! But we had to enable some optional checks: what are they?
The list of all Ruff rules is very comprehensive (over 800 rules when I'm reading it). Instead of leveraging plug-ins, all rules are implemented within Ruff itself and assigned new codes. Specifically:
- Prefix
Bis for rules imported from flake8-bugbear (that we used earlier) - Prefix
RUFis for Ruff-specific rules that weren't imported from any existing linter.
Neither is enabled by default.
I didn't know these prefixes before; I cheated and ran ruff check --select=ALL before to see what sticks. The output with --select=ALL is more noisy because it also has some errors from pydocstyle (prefix D).
Conclusions
I feel I now have a good justification to switch the project over to Ruff, but also I learned that I shouldn't necessarily trust Ruff's defaults and it's worth the shot to go over the rule list and find some additional ones that make sense to enable. The default set is better than nothing, but it's more conservative than my expectations for production code.