Practical infra coding

Debug labels leaking between calls (mutable default argument)

easyDebug and review code

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)

Python
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):

TEXT
api: app=api
web: app=api,app=web
worker: team=core,app=worker
team 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.

ComplexityTime O(k)Space O(k)
Python
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_labels to take **extra_labels and 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.