Why did my function change the list I passed into it?
Short answer
Arguments are passed by reference, so a function that modifies a list or DataFrame changes the caller's object. Return a new object instead, or copy inside the function, so callers are not surprised.
The short version
Python passes a reference to the object, not a copy. So if a function modifies a mutable argument (a list, a dictionary, a DataFrame), the caller's object is changed too. Either return a new object and leave the input alone, or copy it explicitly inside the function.
Seeing the surprise
def add_item(items):
items.append("new") # modifies the CALLER's list
return items
original = ["a"]
result = add_item(original)
print(original) # ['a', 'new'] , the original changed!
The caller passed a list expecting a new one back and got their own list mutated as a side effect. If they still needed the original, it is gone.
Why this happens
The name items inside the function refers to the very same list object as original outside it. There is no copy. Calling append on it mutates that shared object, so both names see the change.
Immutable arguments (numbers, strings, tuples) cannot be changed in place, so this never bites for them, which is why the behaviour is easy to overlook until a list or a DataFrame is involved.
The fix: do not mutate the input
def add_item(items):
return items + ["new"] # builds a new list, original untouched
A function that takes an input and returns a new output, without changing anything it was given, is far easier to reason about. You can call it twice, reorder it, or reuse the input, and nothing surprising happens.
The same rule in pandas
This is exactly why pipeline steps should use assign rather than direct column assignment.
# risky: mutates the caller's frame
def add_tax(df):
df["taxed"] = df["amount"] * 1.2
return df
# safe: returns a new frame
def add_tax(df):
return df.assign(taxed=df["amount"] * 1.2)
The first version silently adds a column to whatever frame the caller passed in, which causes baffling bugs when the same frame is used elsewhere. The second leaves the input exactly as it found it.
If you must mutate, say so
Sometimes in-place modification is the point (a method on a class that manages its own state). Then make it obvious: name the function so it reads as an action (update_scores, normalise_in_place), return None rather than the object, and document it. Returning the object and mutating it is the worst of both, because it looks pure and is not.
Copy defensively only when needed
Copying every argument is wasteful, especially for large frames. Copy when you genuinely need to modify the input and cannot avoid it. Otherwise the discipline of building a new result, rather than editing the input, gives you the safety for free and usually produces clearer code as well.