Skip to main content
CodeOath
← All posts

Python105 min total · 18 parts

Python Fundamentals for Interviews: Data Structures, Comprehensions, and Gotchas

Part 8 of 18 · ~2 min

The Mutable Default Argument Bug

If there's one Python surprise practically guaranteed to show up in an interview, it's this one, and the watcher walks straight into it the first time someone tries to tally offenders across more than one batch:

def flag_offenders(entries, offenders=[]):   # DANGER
    for e in entries:
        if e.status == 401:
            offenders.append(e.ip)
    return offenders

flag_offenders(morning_batch)     # ["198.51.100.7"]
flag_offenders(afternoon_batch)   # ["198.51.100.7", "198.51.100.7", "203.0.113.9"] — NOT a fresh list!

The afternoon report is quietly carrying the morning's offenders inside it. offenders=[] was never a fresh list on each call — it was created exactly once, the moment Python defined flag_offenders, and every call that didn't supply its own offenders has been appending to that same object ever since. Run the watcher as a long-lived process, calling this function once per batch all day, and the list grows forever and never resets, no matter how many separate "reports" you thought you were generating.

def flag_offenders(entries, offenders=None):
    if offenders is None:
        offenders = []
    for e in entries:
        if e.status == 401:
            offenders.append(e.ip)
    return offenders

Here's the detail worth sitting with: this is the exact same rule — default values are evaluated once, at definition time — that just fixed the late-binding closure bug one chapter ago. There, a default evaluated eagerly was the cure, freezing the loop variable's value on purpose. Here, a default evaluated eagerly is the disease, because [] isn't a value, it's a mutable container that every caller ends up sharing. Same mechanism, opposite outcome, and the only variable in the equation was whether the default happened to be mutable. 0, "", None, a fixed tuple — none of these can ever drift between calls, because there's no operation that changes one of them without producing a brand-new object in the process, so sharing the "same" default across every call is completely invisible; nothing was ever there to mutate.

If it still feels like a strange coincidence rather than a rule, the function object itself will show you directly. Defaults aren't recreated per call — they're stored once, as an attribute on the function:

flag_offenders.__defaults__   # (["198.51.100.7", "198.51.100.7", "203.0.113.9"],) — the SAME list, growing

That tuple is the actual object being shared. Nothing mysterious is happening at call time — every call that skips offenders is handed a reference to whatever is sitting in __defaults__ right now, which is why it keeps growing.