Debug labels leaking between calls (mutable default argument)
Problem statement
A deploy helper builds the label list attached to each service. A colleague reports that the second service deployed in a run gets the first service's app label as well, and that a shared team list mysteriously grows. Review the code, name every bug, and fix it.
labels.py (broken)
def build_labels(app, extra=[]): extra.append(f"app={app}") return extra def render(app, labels): return f"{app}: {','.join(labels)}" print(render("api", build_labels("api")))print(render("web", build_labels("web"))) team = ["team=core"]print(render("worker", build_labels("worker", team)))print("team list afterwards:", team)Symptom (actual output of the broken script):
api: app=apiweb: app=api,app=webworker: team=core,app=workerteam list afterwards: ['team=core', 'app=worker']web should only have app=web, and the caller's team list should not change.
Examples
Example 1
Input: `python labels.py` (fixed version)
Output: api: app=api
web: app=web
worker: team=core,app=worker
team list afterwards: ['team=core']
Hints
Approach
Optimal
There are two bugs, and fixing only the famous one leaves the second.
Bug 1: mutable default argument. Python evaluates extra=[] once, when the def statement runs, not on each call. Every call that relies on the default gets the same list object. The first call appends app=api to it; the second call appends app=web to the same list and returns it, so web carries both labels. In a long-running process (a deploy daemon, a web app) this list keeps growing forever.
Bug 2: mutating the caller's argument. When a list is passed in, extra.append(...) modifies the caller's own list. After building labels for worker, the shared team list contains app=worker, and the next service built from team would inherit it. Functions that return a new value should not also change their input.
Fix. Use None as the default and build a fresh list inside the function: labels = list(extra) if extra is not None else []. list(extra) copies whatever was passed (and also accepts a tuple), so the caller's data is never touched. Check is not None rather than truthiness, so an explicitly passed empty list is handled the same way as any other list.
The same trap applies to any mutable default: {}, set(), or an object instance. Linters flag it (for example Pylint's dangerous-default-value or Ruff's B006), which is worth turning on in CI.
k is the number of labels.
O(k)Space O(k)def build_labels(app, extra=None): labels = list(extra) if extra is not None else [] # new list every call, caller's list untouched labels.append(f"app={app}") return labels def render(app, labels): return f"{app}: {','.join(labels)}" print(render("api", build_labels("api")))print(render("web", build_labels("web"))) team = ["team=core"]print(render("worker", build_labels("worker", team)))print("team list afterwards:", team)Follow-up questions
- Rewrite
build_labelsto take**extra_labelsand return a dict instead of a list of strings. - How would you write a unit test that would have caught both bugs?
- The function is now called from several threads. Is the fixed version safe?
Frequently asked questions
Automation code is full of helpers that build lists of tags, labels, hosts or flags. This bug is silent: nothing crashes, resources just get the wrong metadata, and it only shows up when the function is called more than once in the same process. Review questions use it because it is a classic that tests whether you know how Python evaluates defaults.
A single call, or a test that runs each case in a fresh interpreter, never reuses the default list. The bug needs two calls in one process, which is exactly what a real deploy run does.
Occasionally it is used as a crude cache that survives between calls. That is surprising to readers; functools.lru_cache or an explicit module-level dict says the same thing clearly.