skip to content

What goes wrong with `def render(doc, store=PdfStore())` as an injection seam in Python?

level: middleimportance: should knowfreq 45%

answer

  1. When does the default expression run?
  2. Once, at def time - not per call
  3. The object lives on the function itself
  4. Shared state leaks across calls and tests
  5. Sentinel None, or a factory callable

basics

~20 s

The default expression runs once, when the def statement executes at import time, so every call that omits the argument shares one collaborator built before any test ran. Default to None and construct inside the body instead.

solid answer

~40 s

Default values are evaluated once, when the `def` executes, and the resulting object is stored on the function in `__defaults__`. So `store=PdfStore()` constructs the real collaborator **at import time** - opening its connection or temp directory before a single test runs - and then hands that same instance to every call that omits the argument. Anything it accumulates leaks between calls and between tests: an in-process page cache in an invoice-PDF renderer grows unbounded across a long run, and one test's saved documents are visible to the next. The idiomatic seam is a sentinel: `def render(doc, store=None)` with `store = PdfStore() if store is None else store` in the body, or a factory default `store_factory=PdfStore` that the body calls. In a dataclass the equivalent is `dataclasses.field(default_factory=PdfStore)`.

code

python · 23 lines
python
class Store:
    def __init__(self):
        self.saved = []


def render_bad(doc, store=Store()):
    store.saved.append(doc)
    return store


def render_good(doc, store=None):
    if store is None:
        store = Store()
    store.saved.append(doc)
    return store


first = render_bad("INV-17")
second = render_bad("INV-18")
print(second.saved)                             # ['INV-17', 'INV-18']
print(first is second)                          # True
print(render_bad.__defaults__[0] is first)      # True
print(render_good("INV-17").saved, render_good("INV-18").saved)

go deeper

for a junior

Remember one sentence: the default is evaluated once, when the def runs, and every call that omits the argument shares that object. Default to None and build the real thing inside the body.

for a middle

Be able to explain where the object lives - on the function, in __defaults__ - and why import-time construction plus shared state breaks test independence. Show both fixes: the None sentinel and a factory callable.

for a senior

Connect it to symptoms you have actually chased: resident memory climbing through a long run, tests that only fail in a particular order, and I/O happening at import. Say how you would find every instance of the pattern rather than fixing the one in front of you.

for a principal

Decide the convention: whether collaborators are constructed at a single composition root or defaulted per function, and back it with a lint rule so the shape cannot come back through review. Weigh the ergonomics cost of required parameters against the class of bug it removes.

This is the mutable-default trap wearing the costume of dependency injection, and it bites harder here because the shared object is a *collaborator* rather than a list. ### What Python actually does When the interpreter executes a `def` statement, it evaluates the default expressions **once**, right there, and attaches the resulting objects to the function object - positional defaults in `__defaults__`, keyword-only defaults in `__kwdefaults__`. The function body has not run; nothing has been called. Every later call that omits the parameter is handed the *same* object. ```python def render(doc, store=PdfStore()): ... render.__defaults__ # (<PdfStore object ...>,) - built at import time ``` So writing the real collaborator into the signature has three separate consequences. **1. Construction happens at import time.** Importing the module builds a `PdfStore`. If its `__init__` opens a socket, reads configuration, creates a temp directory, or resolves credentials, that happens when the test runner imports the module, before any test decides what should happen. Import errors become collection errors, and a machine with no access to the real backend cannot even import the module to run the pure-logic tests. **2. One instance is shared by every call.** That is the opposite of what an injection seam is for. Any state the collaborator accumulates - a page cache, a connection pool, a list of rendered documents - persists across calls, across tests, and for the whole process lifetime. For a renderer that memoises rendered pages, this is exactly the unbounded-memory-growth shape: resident memory climbs steadily through a long suite and nothing in the test code looks wrong, because the growth lives on a function attribute nobody thinks about. **3. Tests stop being independent.** Test A leaves documents in the shared store; test B asserts on "the" saved set and sees them. Reorder the suite and the failures move. A test that mutates the default object has poisoned every later caller in the process, including production code paths exercised later in the same run. ### The idiomatic fixes **Sentinel default.** The standard shape: ```python def render(doc, store=None): if store is None: store = PdfStore() ... ``` Now the default is the immutable singleton `None`, a fresh collaborator is built per call that needs one, and a test passes a fake. Use `is None`, not `if not store`, because a legitimate empty fake may be falsy. When `None` is itself a meaningful value for the parameter, make a private sentinel: ```python _MISSING = object() def render(doc, store=_MISSING): if store is _MISSING: store = PdfStore() ``` **Factory default.** Pass the *callable* rather than an instance, which is safe because a class object is created once anyway and calling it is what produces per-call state: ```python def render(doc, store_factory=PdfStore): store = store_factory() ``` A test passes `FakeStore`, or `functools.partial(FakeStore, quota=10)` when the fake needs configuring. This is the right shape when the function genuinely owns the collaborator's lifetime; the sentinel shape is right when the caller usually supplies it. **Dataclasses.** In a `dataclasses.dataclass`, a mutable or stateful default must go through `dataclasses.field(default_factory=PdfStore)`; a bare `store: PdfStore = PdfStore()` has the same shared-instance problem, and for genuinely mutable built-ins like `list` the dataclass machinery raises `ValueError` at class-creation time rather than letting you do it. **Hoist it out entirely.** Often the honest answer is that the function should not know how to build its collaborator at all: make it a required parameter and construct it once at the program's entry point. Defaults are a convenience for call sites, not an architecture. ### The one case where the inline default is fine If the default object is immutable and cheap - a frozen configuration, an empty tuple, an enum member, a stateless helper with no I/O - the shared instance is harmless, and that is why the idiom survives in the wild. The rule of thumb is: share defaults that have no state and no side effects at construction; never share a default that opens something, remembers something, or could be swapped for a fake. ### Spotting it in review The tell is a call in a signature. Any `(` `)` on the right-hand side of a default value deserves a second look, and anything that also does I/O in its constructor is a defect. It is worth a lint rule over the codebase, because it is invisible until a suite gets long enough for the memory graph to bend.

  • Is an inline default ever acceptable for a collaborator?
    Yes, when the object is immutable, stateless and cheap to build - a frozen configuration, an empty tuple, an enum member, a pure helper. The shared instance then cannot leak state or perform I/O at import. The moment the default opens a connection, caches, counts, or is something a test would want to replace, it belongs in the body behind a sentinel or a factory.
  • Why prefer `if store is None` over `if not store`?
    Because a perfectly good collaborator can be falsy. An in-memory fake that defines `__len__` or `__bool__` and happens to be empty evaluates false, so `if not store` throws the injected fake away and silently constructs the real one - a test that appears to pass while exercising production I/O. Identity against the sentinel asks the only question you meant to ask.
  • How would you catch this pattern across a large codebase?
    Grep or lint for a call expression inside a parameter default - anything with parentheses on the right-hand side of `=` in a signature - and review the hits. Most linters ship a rule for mutable defaults; the stateful-collaborator case is the same rule with a wider net. Reviewing signatures is cheap because the defect is invisible at run time until a long process starts growing.

saying these in an interview costs you the question

  • Says the default expression is evaluated on every call
  • Says it is evaluated on the first call that omits the argument
  • Thinks a fresh instance is created per test module import
  • Uses `store or Store()` and misses the falsy-fake case
  • Believes only lists and dicts have the shared-default problem
  • Sees no problem with I/O happening in a default's constructor

context