> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Reviewing Python for the Mutable Default Argument and Its Cousins
- URL: https://debugly.dev/reviewing-python-mutable-defaults/
- Published: 2026-10-09T12:25:00.000Z
- Updated: 2026-10-23T09:12:50.000Z
- Description: The shared list default is the famous one, and reviewers catch it on sight. The module level cache, the captured dict and the class attribute list are its…
- Author: Rohit Bhadani
- Tags: Code Review, Python, Debugging

The pull request contained the classic: a function with a list as a default argument, the one every Python reviewer knows to flag, because the default is evaluated once at definition and shared across every call. It was caught in a second. Three functions below it, the same defect wore different coats, a module level dict used as a cache that two endpoints mutated, a class attribute list appended to by every instance, a default constructed once and threaded through retries, and none of the three drew a comment, because none of them matches the greppable shape.

This is the review guide for the mutable default and its family, because the family is one defect, shared mutable state bound at the wrong time, and the review should catch the defect, not the idiom.

This was Python 3.13.2, and the incidents each cousin produced are the standard shared state incidents, the same class as [reviewing shared state by asking what happens under contention](https://debugly.dev/reviewing-locks-and-shared-state/), in a language where the sharing is implicit in the binding.

## The original, and why it binds once

The default argument is evaluated when the def executes, once, at import, and the resulting object is attached to the function. Every call that omits the argument receives the same object, so an append on one call is visible to the next, and the function carries state it never declared. The review question is not "is there a mutable default" but "does this parameter hold state across calls", and the answer for the classic is yes.

The fix is the sentinel: default to None, build inside the call, so each call owns its container. The review accepts None defaults and questions any literal container in a signature.

## Cousin one: the module level cache

A dict at module scope, appended by handlers, reads as a cache and behaves as global state. Under one worker it is a cache. Under several workers it is per process divergence, and under tests it is cross test leakage, because the module persists across tests in the same process, and test A's entries are test B's fixtures. The review question is who resets it, and if the answer is nobody, the module level dict is a mutable default with a wider blast radius, shared across every caller in the process.

The acceptable version names its lifecycle: a cache object with explicit eviction and a reset hook the tests use, or a real cache with a TTL, because the defect is not the dict, it is the unbounded, unreset sharing.

## Cousin two: the class attribute container

A list or dict declared in the class body is shared by every instance that does not shadow it, and the append through self writes to the class attribute, so instances that never assigned their own list share one. This is the mutable default relocated to the class, and it produces the classic symptom: two unrelated objects, created far apart, observe each other's appends. The review question is whether the attribute is meant to be per instance, and if so it must be created in **init**, per instance, and the class body container is flagged as a shared default.

## Cousin three: the default constructed once and threaded

The subtlest cousin is a default object built once and passed down, a config, a session, a buffer, where the helper's signature takes an optional object defaulting to a shared instance. The shape is the mutable default with the binding moved to a constant, and the sharing is identical: every call that omits the argument mutates the same object. The review question is the same as the original, does the omitted argument yield a fresh object or a shared one, and the answer must be fresh unless the sharing is the documented intent, as it legitimately is for a shared client or pool, in which case the sharing is named in the docstring and the object is treated as the shared resource it is.

## The unifying review question

The family collapses to one question, which is the question to ask on every default and every module or class attribute: is this object created per use, or per definition, and if per definition, is the sharing intended, bounded and resetable. Per definition sharing that is unintended is the bug, regardless of where the def lives, and the location, signature, module, class, constant, is only the coat.

This is also why the fix pattern is uniform: move the creation inside the use, or make the sharing explicit and managed. There is no third option, and the review that checks the question rather than the idiom catches all four shapes with one pass.

## The test that catches the family

The property test is one line of intent: call twice, assert independence. Invoke the function twice with omitted arguments and assert the second call does not see the first call's mutations, and for the class cousin, instantiate twice and assert the same. The test fails for every cousin, because the defect is one, and the test is the review made executable, which is the discipline from [a test that restates the implementation is not a test](https://debugly.dev/tests-that-mirror-the-implementation-are-worthless/), which is why the two fixes look alike.

## What I look for as proof

The strongest signal in a diff is a reset or a sentinel: the None default with internal construction, the cache with eviction and a reset hook, the attribute created in **init**, the shared object documented as shared. Their presence means the author asked the binding question. Their absence, on any container bound at definition, means the question was never asked, and the review is the only place left to ask it.

## The rule

A mutable default is one instance of a general defect, an object created per definition and shared per use, and the cousins live at module scope, in the class body and in shared constants. Ask, on every default and every definition bound container, whether it is created per use or per definition, and whether the sharing is intended, bounded and resetable.

The classic is caught on sight because it is famous. The cousins ship because they are not, and the review that checks the binding, not the idiom, is the review that catches the family, which is one bug wearing four coats.

---

Related reading: [Reviewing the Module-Level Dict That Every Request Wrote To](https://debugly.dev/reviewing-module-level-mutable-state/) explores a connected angle.

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)