Skip to content

Review checklist

This is the checklist to run down — by eye, on any diff, yours or an AI’s — after the tools in Verification Tools have run and come back clean. It fits on one printed page on purpose: a checklist nobody actually opens during a review is worthless. Every line links to the lesson that explains why, and to the exact pattern number in the AI Bug Catalog it guards against.

  • Does a default argument use a mutable literal (= [], = {}, = set()) instead of = None? Catalog #1. See Functions.
  • Does a lambda or nested function inside a loop close over the loop variable itself, rather than its value at each iteration? Catalog #2. See Closures.
  • Does a function that looks read-only actually mutate a list/dict argument in place (.sort(), .append(), x[i] = ...) without documenting it? Catalog #3. See Lists & Tuples.
  • Does [[...] * n] * m (or any *-repeated container of containers) create one shared inner object instead of independent ones? Catalog #4. See Lists & Tuples.
  • Is a class attribute declared at class level (not inside __init__) a mutable object shared by every instance? Catalog #15. See Classes.
  • Is is used anywhere it should be == — comparing values rather than checking a singleton (None/True/False)? Catalog #5. See Variables & Types.
  • Is there a bare except:, an except Exception: pass, or (in async code) an except BaseException sitting between an await and a task’s .cancel()? Catalog #6. See Exceptions.
  • Does raise e appear inside except ... as e: where a bare raise was intended? Catalog #7. See Exceptions.
  • Does assert validate external input (arguments, user input, API responses) rather than an internal invariant? It vanishes under -O. Catalog #8. See Exceptions.
  • Is every coroutine-returning call actually awaited, or handed to asyncio.create_task/asyncio.gather? Catalog #9. See asyncio basics.
  • Does every asyncio.create_task(...) result get stored somewhere with a lifetime longer than the function that created it? Catalog #10. See Tasks & gather.
  • Does any async def function call a blocking function (time.sleep, a synchronous HTTP client, blocking file I/O) instead of its await-based equivalent? Catalog #11. See GIL & threads.
  • Is shared mutable state read-then-written across an await point with no asyncio.Lock? Catalog #12. See Tasks & gather.
  • Does every multiprocessing.Pool/Process call sit behind if __name__ == "__main__":? Catalog #13. See multiprocessing.
  • Does a class define __eq__ without also defining __hash__ (or accepting, on purpose, that instances become unhashable)? Catalog #14. See Dunder methods.
  • Is Any used where a real type was available, or does a # type: ignore appear with no specific error code? Catalog #16. See Type hints.
  • Is an X | None (or Optional[X]) parameter or return value used without a None check before the value is used? Catalog #17. See Type hints.
  • Is a list[Subtype] passed where list[Supertype] is expected — and would Sequence/Iterable (covariant) actually be the correct parameter type instead of list? Catalog #18. See Type hints.
  • Does the code call datetime.utcnow() (deprecated, naive) instead of datetime.now(timezone.utc)? Catalog #19. See stdlib tour.
  • Is every open() call inside a with block, and does it pass encoding= explicitly? Catalog #20. See stdlib tour.
  • Does any subprocess call use shell=True with a string built from external input? Catalog #21. See stdlib tour.
  • Does every @patch("...") target string match how the code under test actually imported the name — the module that calls it, not the module that defines it? Catalog #22. See pytest.
  • Is every dependency pinned, and has pip-audit/uv’s audit tooling run against the lockfile recently? Is anything ever installed with sudo pip? Catalog #23. See venv, pip & uv.
  • Does library code call print() where logging belongs — anything the caller needs to be able to filter, redirect, or silence? Catalog #24. See Logging.

🐛 Spot the bug

def get_config(overrides: dict | None = None) -> dict:
config = {"retries": 3, "timeout": 10}
if overrides:
config |= overrides
return config
base = get_config()
custom = get_config({"retries": 5})
print(base)
print(custom)
Show the answer

This one is actually correct — it is here to test whether you run the checklist mechanically or actually read the code. overrides: dict | None = None uses None as the default, not a mutable literal, so catalog #1 does not apply: config = {...} is a fresh dict created inside the function body on every call, and config |= overrides merges into that fresh dict, not into anything shared across calls. Running the checklist should tell you where to look, not hand you a bug that isn’t there — base correctly prints {'retries': 3, 'timeout': 10} and custom prints {'retries': 5, 'timeout': 10}, independently.

Mutability: mutable default arg? loop-var closure? mutates caller's list? shared [[...]]*n rows? shared class attr?
Errors: is vs ==? bare/broad except (incl. BaseException across await)? raise e vs raise? assert validating input?
Async: every coroutine awaited/tasked? task reference kept? blocking call inside async def? shared state without Lock? Pool guarded by __main__?
Typing: __eq__ without __hash__? bare Any / uncoded type: ignore? Optional narrowed before use? list invariance respected?
Resources: utcnow()/naive datetime? open() with encoding=? subprocess shell=True? patch target matches import site? deps pinned + audited?
Tooling: print() instead of logging in library code?
Which group does 'does every asyncio.create_task result get stored somewhere' belong to, and why that group rather than 'Typing'?
Why does the checklist ask specifically 'does every @patch target match how the code under test imported the name' rather than just 'is there a mock'?
The checklist tells you to check whether Pool/Process calls sit behind if __name__ == "__main__":. Why does this matter more on some platforms than others?
Why is 'is Any used where a real type was available' on this checklist at all, given that mypy --strict and pyright both report zero errors on a file using Any exactly this way?
Does catalog pattern #1 (mutable default argument) apply here?
def get_config(overrides=None):
    config = {"retries": 3}
    if overrides:
        config |= overrides
    return config