Reviewing Python code - what to flag, and what ruff, mypy and the interpreter already flag for you
USE WHEN: you are reviewing, critiquing or auditing existing Python code - a
"code review", a "review" of a .py file, a diff, a PR or a pull request;
deciding what to comment on in Python; avoiding false positives on Python code
DO NOT USE FOR: writing, explaining or learning Python - use
`languages/python`; anything ruff's default rules or a configured mypy already
reports (this skill lists t...
Installs into .claude/skills of the current project.
Are you the author of Python?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/claude-dev-suite-python-50f2592c)
---
name: python-review
description: |
Reviewing Python code - what to flag, and what ruff, mypy and the interpreter already flag for you
USE WHEN: you are reviewing, critiquing or auditing existing Python code - a
"code review", a "review" of a .py file, a diff, a PR or a pull request;
deciding what to comment on in Python; avoiding false positives on Python code
DO NOT USE FOR: writing, explaining or learning Python - use
`languages/python`; anything ruff's default rules or a configured mypy already
reports (this skill lists those so you can stay silent about them);
Django/FastAPI specifics - use the framework skills; test review - use the
pytest skills
allowed-tools: Read, Grep, Glob
---
# Reviewing Python
A reviewer's leverage is what the toolchain cannot say. Every check below
describes a defect that survives the interpreter and **ruff's default rule
set**, which is narrower than most people assume: `E4`, `E7`, `E9` and `F`.
That default is the crux. Most of the famous Python footguns live in rule
families that are opt-in — `B` (bugbear), `ASYNC`, `DTZ`, `S` (bandit). Read
`pyproject.toml` before deciding whether a finding is yours or the linter's.
The snippets are fragments cut down to the defect, not runnable programs.
## Already covered - do not spend review on it
| Defect | Reported by |
|---|---|
| Unused import, unused local | ruff `F401` / `F841` (default) |
| Bare `except:` | ruff `E722` (default) |
| `is` compared against a literal | ruff `F632` (default) |
| Undefined or redefined name | ruff `F821` / `F811` (default) |
| f-string with no placeholders | ruff `F541` (default) |
| Duplicate dict key | ruff `F601`-family (default) |
| Mutable default argument | ruff `B006` - **opt-in**, not in the default set |
| Naive `datetime.now()` | ruff `DTZ005` - **opt-in** |
| Blocking call inside `async def` | ruff `ASYNC` family - **opt-in** |
| `subprocess` with `shell=True` | ruff `S602` / bandit - **opt-in** |
| Missing or wrong type annotation | mypy - only if the project runs it, and only where annotations exist |
If a project pins ruff to the default set, the four opt-in rows above are
**your** job. Establish which it is by reading the config, once, rather than
guessing per file.
> **The table above dates; the tools do not.** Verify a default set with
> `mcp__documentation__fetch_docs` on technology `ruff` — topics `rules`, `settings` (where `select` is decided).
> Those entries point at the indexes the tool authors maintain, so they follow
> the version the project actually pins rather than the one this skill was
> written against.
## The checks that earn their place
### A coroutine created and never awaited
```python
async def handler():
save_to_db(record) # returns a coroutine; nothing runs
return "ok"
```
**When you see it**: a call to an `async def` without `await`, inside another
`async def`.
**Ask**: did this actually execute? Python emits a `RuntimeWarning` at garbage
collection - to stderr, at an unrelated moment, and routinely swallowed by a
logging config. mypy catches it only when the callee is annotated and the result
is unused in a checked context. In practice this ships.
### A task created with no reference held
```python
asyncio.create_task(background_work()) # nothing holds the result
```
**When you see it**: `create_task` whose return value is discarded.
**Ask**: what keeps this alive? The event loop holds only a *weak* reference, so
a task can be garbage-collected mid-flight and simply stop - intermittently,
under load, which is the worst way to find out. The fix is keeping the task in a
set until it completes, or `TaskGroup` on 3.11+.
### A blocking call inside async code
```python
async def fetch_all(urls):
for u in urls:
r = requests.get(u) # blocks the entire event loop
time.sleep(0.1) # so does this
```
**When you see it**: `requests`, `time.sleep`, a synchronous DB driver, or file
I/O inside `async def`.
**Ask**: what else is on this loop? One blocking call stalls every other
coroutine in the process, so the symptom appears in unrelated endpoints. The
`ASYNC` rules find these but are opt-in.
### An exception caught and its cause discarded
```python
try:
parse(payload)
except ValueError:
raise ApiError("bad request") # original traceback context is implicit
```
**When you see it**: a `raise` inside an `except` block with no `from`.
**Ask**: will the person debugging this see what actually failed? Python does
chain implicitly ("During handling of the above exception..."), but `raise ...
from err` states the causal link deliberately, and `from None` is how you say
the original genuinely does not matter. Silence leaves the reader unsure which
was meant.
### `except Exception` with nothing distinguishing
```python
try:
do_work()
except Exception:
logger.warning("failed") # a typo in do_work reads the same as a timeout
```
**When you see it**: a broad `except` whose handler does not re-raise and does
not branch on the exception.
**Ask**: which failures is this meant to absorb? `E722` only covers *bare*
`except:` - `except Exception` passes the linter while catching `AttributeError`
from a refactor exactly as it catches the network error it was written for.
### A mutable default argument
```python
def add(item, target=[]): # one list, shared by every call
target.append(item)
return target
```
**When you see it**: `[]`, `{}`, `set()` or a call expression as a default.
**Ask**: is the default meant to be per-call? The default is evaluated once at
function definition. `B006` reports it but is **not** in ruff's default set, so
in most repos this is a review finding.
### A class attribute shared as if it were per-instance
```python
class Session:
items: list[str] = [] # one list for the whole class
```
**When you see it**: a mutable value assigned at class level, especially with an
annotation that makes it look like a field declaration.
**Ask**: was a dataclass field or `__init__` assignment intended? The annotation
reads like a per-instance declaration and behaves like the opposite. No default
linter reports it.
### A collection mutated while it is being iterated
```python
for item in items:
if item.expired:
items.remove(item) # skips the element after each removal
```
**When you see it**: `.remove()`, `.pop()`, `del` or a dict assignment inside a
loop over the same collection.
**Ask**: does the loop still visit every element? Removing shifts the index the
iterator is holding, so it silently skips - no exception for lists, and
`RuntimeError` only for dicts and sets. A comprehension building a new list is
the fix.
### A generator consumed twice
```python
rows = (parse(line) for line in f)
total = sum(r.amount for r in rows)
count = len(list(rows)) # 0: the generator is already exhausted
```
**When you see it**: a generator expression or `map`/`filter` result used in more
than one place.
**Ask**: is this iterated once? The second pass yields nothing and produces a
plausible wrong number rather than an error. Materialise with `list()` if it is
needed twice.
### A naive datetime crossing a boundary
```python
created = datetime.now() # no tzinfo; means whatever the host is set to
if created > deadline_utc: # TypeError, or a silent 2-hour error
```
**When you see it**: `datetime.now()`, `utcnow()`, or `fromtimestamp()` without
`tz=`, on a value that is stored, compared or serialised.
**Ask**: whose clock is this? `utcnow()` is the sharpest edge - it returns a
naive datetime holding UTC, so it compares wrongly against both aware datetimes
and local naive ones. `datetime.now(timezone.utc)` is the version that says what
it means. The `DTZ` rules cover this and are opt-in.
### A shallow copy treated as a deep one
```python
new_cfg = dict(cfg)
new_cfg["tags"].append("x") # mutates the original's nested list
```
**When you see it**: `dict(...)`, `.copy()`, `list(...)` or a slice, followed by
a write into a nested member.
**Ask**: does anything else hold the nested object? `copy.deepcopy` is the fix
when it genuinely needs isolation - and worth a second look, since it is often
reached for when restructuring the data would be better.
### `dict.get` whose `None` flows onward
```python
timeout = config.get("timeout") # None when absent
sock.settimeout(timeout * 2) # TypeError, far from the missing key
```
**When you see it**: `.get()` with no default, on a path that uses the result
arithmetically or as an attribute.
**Ask**: is absence expected here? If it is, give the default at the lookup;
if it is not, `config["timeout"]` raises `KeyError` at the line that is actually
wrong, naming the key.
## Config-dependent - read `pyproject.toml` before commenting
Python's review surface is set almost entirely by configuration. Read it once,
state what you find once, and let it decide which findings above are yours.
| What to read | Why it changes the review |
|---|---|
| `[tool.ruff.lint] select` | The default is `E4, E7, E9, F`. If the project has not widened it, the `B`, `ASYNC`, `DTZ` and `S` findings above are unreported and worth raising |
| `[tool.mypy] strict` / `disallow_untyped_defs` | Without them, annotations are decoration: an unannotated function is not checked at all, so a wrong type hint elsewhere is never contradicted |
| `requires-python` | Decides whether `TaskGroup` and `except*` (3.11+), or `Self` and `override` (3.12+) are available - do not suggest what the floor forbids |
| Presence of a mypy/pyright config at all | If neither runs, every type annotation in the diff is unverified prose |
`# type: ignore` without a rule code silences everything on that line, forever.
A bare one in a diff is worth a comment even when the surrounding change is fine.
## What to say
Anchor the comment to the line, name the condition that triggers the defect, and
say what breaks: "`utcnow()` returns a naive datetime, so this comparison
against an aware `deadline` raises `TypeError` at request time" beats "use
timezone-aware datetimes". If you cannot state the input that fails, it is a
preference, not a defect.
When the finding exists only because a rule family is disabled, say so and point
at the config. One comment about `select` is worth more than ten about mutable
defaults.