A Python service stores the list a caller passed to add_order(items), and its memory climbs all day. What is wrong at that boundary?
answer
- Nothing was copied at the call
- Two owners, one mutable object
- The store holds a live handle
- Snapshot on the way in and out
- copy.copy or tuple at the boundary
basics
~20 sThe call copied nothing, so the service stored the caller's own list object. The caller keeps appending to it, so the stored order silently grows and nothing can be reclaimed. Take a snapshot at the boundary instead.
solid answer
~40 sPassing a list into a function binds a name to the caller's object; storing that parameter keeps the caller's live list, not a snapshot. Two owners now share one mutable object: every SKU the caller appends afterwards appears inside the stored order, and because the store still references those lists nothing is collectable, so resident memory climbs. Confirm it by comparing `id()` of the stored object with the caller's, or by evaluating `stored is caller_list`; a `tracemalloc` diff will point at the growing allocations. Fix it by copying at the public boundary — `copy.copy(items)` or `list(items)` on the way in, and never returning the internal container raw on the way out. A shallow copy is enough when the elements are immutable; storing `tuple(items)` also freezes the snapshot against later mutation.
code
python · 17 linesimport copy
class PickListStore:
def __init__(self):
self._orders = []
def add_order(self, items):
self._orders.append(copy.copy(items))
def orders(self):
return [copy.copy(order) for order in self._orders]
pending = ["sku-1"]
store = PickListStore()
store.add_order(pending)
pending.append("sku-2")
print(store.orders()) # [['sku-1']]go deeper
Know that passing a list into a function copies nothing: the function and the caller hold the same object, so anything the function keeps stays wired to the caller's list and changes with it.
Explain the aliasing and the fix — take a snapshot with list(items) or copy.copy(items) when you retain a caller's mutable argument — and note that the copy is shallow, so mutable elements are still shared.
Diagnose it on a running service: compare id() or use is to prove the store and the caller share an object, read a tracemalloc diff for the growth, and then defend the boundary in both directions, not only on the way in.
Own the ownership policy. Say per API whether arguments are borrowed or captured, decide where copying is affordable versus where an immutable type is the answer, and treat a returned internal container as a leak rather than a convenience.
### What actually went wrong The service never copied anything. `add_order(items)` bound a parameter name to the caller's list object, and storing it appended *that object* to the internal collection. The submitting code kept its own name for the same list and kept appending to it. There are now two owners of one mutable object, and neither knows about the other: * Every SKU the caller adds after the call appears inside the stored order, silently changing data the service believes it captured. * Nothing the service holds can ever shrink. The store keeps every submitted list reachable, and every list keeps growing, so resident memory climbs all day and the garbage collector has nothing to reclaim — the objects are genuinely still referenced. Blaming the collector here is the standard wrong diagnosis. The tell is cheap to check: compare `id()` of the object the store holds with `id()` of the caller's list, or evaluate `stored is caller_list`. Same object means an alias, not a snapshot. If you only have a running process, a `tracemalloc` snapshot diff across a few minutes will point at the allocation site of the growing lists, and the retained-object count under the store will grow in lockstep with submissions. ### The fix: copy at the public boundary A function that merely *reads* a mutable argument during the call can share it safely. A function that **retains** one must take a snapshot, because retention turns a brief share into a permanent alias: ```python import copy class PickListStore: def __init__(self): self._orders = [] def add_order(self, items): self._orders.append(copy.copy(items)) # or list(items) def orders(self): return [copy.copy(order) for order in self._orders] ``` `copy.copy(items)` (equivalently `list(items)` or `items.copy()` for a list) allocates a new list whose slots point at the same element objects. The caller's later `append` now lands on the caller's list only. If the elements are immutable — strings, ints, frozen value objects — a shallow copy is a complete snapshot and you are done. If the elements are themselves mutable, the copy shares them and you have moved the aliasing problem one level down rather than solving it; that is when you either copy per element, make the element type immutable, or accept the sharing deliberately and write it down. ### Copy on the way out, too Half-defended boundaries are the common version of this bug: the constructor copies, and then an accessor does `return self._orders`, handing every caller a live handle to internal state. Return a copy, return an immutable form, or return an iterator that does not expose the container. For a list, `tuple(items)` is a strong choice for a stored snapshot — it is a copy *and* it cannot be mutated by anyone later, including future maintainers of your own class. For a dict, `types.MappingProxyType(d)` gives callers a read-only view without a copy, though the underlying dict can still change beneath them. Since Python 3.13, `copy.replace(obj)` builds a modified copy of an object that supports `__replace__`, which pairs well with immutable value objects at a boundary like this one. ### When not to copy Copying is not free, and "deep-copy every argument" is as wrong as copying nothing. `copy.deepcopy` walks the entire object graph, is slow, and will happily duplicate things you never intended to duplicate — a cache, a connection handle, a shared config object. Three defensible positions, in order of preference: 1. **Copy at the boundary** when the retained object is small relative to the work being done. A pick list of tens of strings is nothing; copy it and stop thinking about it. 2. **Freeze instead of copy** — accept or immediately convert to a tuple or a frozen value type, so no later mutation is possible from either side. 3. **Document ownership explicitly** when the payload is genuinely large and copying it would dominate: state in the signature's docstring that the callee takes ownership and the caller must not touch the object afterwards. This is a real engineering choice, but it is only as strong as the team's discipline. That last point is where this stops being a language question. On a four-person team, an ownership rule that lives in one author's head is re-broken within a month by a reviewer who cannot see it in the code. Make the rule visible: copy in the constructor or the setter, never return internal containers raw, and prefer parameter types that cannot be mutated in the first place. Then the boundary is enforced by what the code does rather than by what everyone remembers, and the memory growth that started this investigation cannot recur through a new caller.
- Once you copy on the way in, where does the same alias still leak?On the way out. An accessor that does `return self._orders` hands every caller a live handle to internal state, so the next mutation happens inside your object. Return a copy, return an immutable form such as a tuple, or return an iterator. For a dict, `types.MappingProxyType` gives a read-only view without copying, though the underlying dict can still change.
- When is `copy.copy` the wrong tool here?When the elements are themselves mutable: a shallow copy duplicates the container but shares every element, so the aliasing simply moves one level down. Then you copy per element, make the element type immutable, or accept the sharing deliberately. Reaching for `copy.deepcopy` by default is the other error — it walks the whole object graph and will duplicate caches, handles and config you never meant to clone.
- What if the payload is too large to copy at every call?Then make ownership explicit rather than implicit: document in the signature that the callee takes ownership and the caller must not touch the object afterwards, or accept an immutable type so the question cannot arise. A documented ownership rule is a legitimate engineering choice, but it is only as strong as the team's discipline and should be visible at the call site.
saying these in an interview costs you the question
- Thinks passing a list into a function already copies it
- Blames the garbage collector for memory that is still referenced
- Reaches for copy.deepcopy on every argument by default
- Copies on the way in but returns the internal list unguarded
- Treats a 'do not mutate' docstring as enforcement
- Cannot name a check that distinguishes an alias from a snapshot