Q&A library Python

Why did my function change the list I passed into it?

Python Intermediate functionsmutabilityside-effects

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.

Need this solved on your own data?

The library is free and always will be. When the problem is bigger than an answer, we take on the work.

Start a project