Defining Functions with List Arguments in Python: Complete Mutable Parameter Handling and Best Practices

Defining functions with list arguments in python

I got burned by this early in my Python journey: I wrote a function with a default argument of [], called it a few times, and watched data mysteriously accumulate across completely unrelated calls. It took me an embarrassingly long time to figure out why. That bug is basically a rite of passage for Python developers, and it’s the reason I now think carefully every time I define a function that takes a list as a parameter. This guide covers everything I’ve learned about handling list arguments safely — from the basics to the internal memory model that explains why the classic mutable-default bug happens at all.

Passing a List as a Regular Argument

At the simplest level, passing a list into a function works exactly like passing any other object:

def print_items(items):
    for item in items:
        print(item)

my_list = ["apple", "banana", "cherry"]
print_items(my_list)

Output:

apple
banana
cherry

Nothing surprising here. The interesting behavior starts once you realize Python passes objects by reference (more precisely, it passes the object reference by value — a distinction I’ll unpack shortly).

Python’s Argument-Passing Model: “Pass by Object Reference”

Python doesn’t fit cleanly into “pass by value” or “pass by reference” as those terms are used in languages like C++ or Java. What actually happens is that the function parameter becomes a new local name bound to the same object the caller passed in. If that object is mutable (like a list), changes made through that reference are visible to the caller. If you rebind the parameter to a new object, that only affects the local name, not the caller’s variable.

def modify_list(lst):
    lst.append(4)  # mutates the original object

def reassign_list(lst):
    lst = [100, 200]  # rebinds the local name only

numbers = [1, 2, 3]
modify_list(numbers)
print(numbers)  # [1, 2, 3, 4] -- the caller's list WAS changed

reassign_list(numbers)
print(numbers)  # [1, 2, 3, 4] -- unchanged, because reassignment only affects the local name

Understanding this distinction — mutation vs rebinding — resolved most of my early confusion about “does Python pass by value or reference.”

The Classic Mutable Default Argument Trap

Here’s the bug that got me:

def add_item(item, target_list=[]):
    target_list.append(item)
    return target_list

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']  <- surprise!

The reason: default argument values are evaluated once, at function definition time, not on every call. That empty list [] is created a single time and stored as part of the function object itself (add_item.__defaults__). Every call that doesn’t explicitly pass target_list reuses that exact same list object.

print(add_item.__defaults__)  # (['a', 'b'],)

I actually printed __defaults__ the first time I debugged this, and seeing the accumulated list sitting there inside the function object’s metadata made the whole mechanism click instantly.

The Correct Pattern: None as Sentinel

The fix I use in virtually every function that needs a “default empty list” is the None sentinel pattern:

def add_item(item, target_list=None):
    if target_list is None:
        target_list = []
    target_list.append(item)
    return target_list

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b']  <- correct, fresh list each time

Now a brand-new list is created inside the function body on every call where the caller didn’t supply one, since [] inside the function body is evaluated fresh each execution, unlike a default parameter value.

Defensive Copying When You Don’t Want to Mutate the Caller’s List

Sometimes I want a function to use a list without risking mutation of the caller’s original data. In that case, I copy it explicitly:

def process_items(items):
    items = items.copy()  # or list(items), or items[:]
    items.append("processed marker")
    return items

original = ["x", "y"]
result = process_items(original)
print(original)  # ['x', 'y'] -- untouched
print(result)     # ['x', 'y', 'processed marker']

I reach for .copy() (shallow copy) for lists of immutable elements, and copy.deepcopy() from the copy module when the list contains nested mutable structures like lists of lists or lists of dictionaries that I also don’t want mutated.

import copy

nested = [[1, 2], [3, 4]]
def modify_nested(data):
    data = copy.deepcopy(data)
    data[0].append(99)
    return data

result = modify_nested(nested)
print(nested)  # [[1, 2], [3, 4]] -- unaffected
print(result)  # [[1, 2, 99], [3, 4]]

A shallow copy here wouldn’t have been enough, since .copy() only copies the outer list — the inner lists would still be shared references.

Accepting Multiple List-Like Arguments via *args

If I want a function to accept an arbitrary number of list-like inputs rather than one fixed list parameter, *args combined with unpacking is useful:

def merge_lists(*lists):
    result = []
    for lst in lists:
        result.extend(lst)
    return result

print(merge_lists([1, 2], [3, 4], [5]))  # [1, 2, 3, 4, 5]

This is distinct from taking a single list parameter — *args collects any number of positional arguments into a tuple, so the caller passes several separate lists rather than one list of lists.

Type Hints for List Parameters

Since Python 3.9, I annotate list parameters using the built-in list generic directly (no need to import List from typing anymore, though it still works for backward compatibility):

def total(values: list[int]) -> int:
    return sum(values)

For older codebases targeting Python 3.8 or earlier, the typing module version is required:

from typing import List

def total(values: List[int]) -> int:
    return sum(values)

Type hints don’t enforce anything at runtime by themselves, but they make intent explicit and let tools like mypy catch mistakes before code ever runs.

Internal Memory Model

Every list in Python is a heap-allocated object containing a reference-counted array of pointers to other objects. When a list is passed to a function, only the pointer to that array (technically, the reference to the list object) is copied onto the new stack frame’s local variables — not the underlying data. This is why mutation is visible across function boundaries but reassignment isn’t: mutation methods like .append(), .extend(), and .sort() operate on the shared underlying array, while = rebinds a local name to point somewhere else entirely, leaving the original array and its other references untouched.

Real-World Use Cases

  • Batch processing functions that accept a list of records and return transformed results.
  • Accumulator functions used inside loops, where the None sentinel pattern prevents state leaking between iterations or calls.
  • API wrapper functions that accept a list of IDs and translate them into a single batched HTTP request.
  • Data validation functions that accept a list of allowed values against which input is checked.
  • Configuration merging, where multiple list-like config sources are combined with *args.

Best Practices and Common Mistakes

  • Never use a mutable object (list, dict, set) as a default argument value — always use None as a sentinel and create the mutable object inside the function body.
  • Be explicit about whether a function mutates its list argument in place or returns a new list; document this in the docstring, since callers can’t tell just from the signature.
  • Use .copy() or copy.deepcopy() when you want to guarantee the caller’s original list is untouched.
  • Prefer returning a new list over mutating in place when the function is meant to be “pure” and side-effect-free — this makes testing and reasoning about the code much easier.
  • Use type hints (list[int], list[str]) to communicate expected content types, even though Python won’t enforce them at runtime.

Troubleshooting Tips

If a function’s default list argument seems to “remember” values across unrelated calls, you’ve hit the mutable default argument bug — switch to the None sentinel pattern.

If mutating a list inside a function doesn’t seem to affect the caller’s list, check whether you accidentally rebound the parameter (lst = [...]) instead of mutating it (lst.append(...)).

If a deep nested structure is unexpectedly shared between two variables after copying, verify you used copy.deepcopy() rather than a shallow .copy().

FAQs

Why does Python evaluate default arguments only once? Default values are attached to the function object at definition time as part of its __defaults__ tuple, and that tuple is created exactly once, when the def statement executes — not on each call.

Is passing a list to a function the same as passing by reference in C++? Not exactly — Python passes a reference to the object, but that reference itself is passed “by value” in the sense that reassigning the parameter inside the function doesn’t affect the caller’s variable, only mutating the object does.

Should I always use *args instead of a single list parameter? No — use a single list parameter when the caller naturally has one collection to pass, and *args when the caller might supply a variable number of individual items or several separate lists.

Does list.copy() also copy nested lists? No, .copy() performs a shallow copy — nested mutable objects inside the list remain shared references; use copy.deepcopy() for full independence.

Summary

Functions that take list arguments in Python are governed by two core ideas: Python’s reference-passing model, where mutation is visible to the caller but reassignment isn’t, and the one-time evaluation of default argument values, which makes mutable defaults dangerous. Once I adopted the None sentinel pattern as a habit and got comfortable with .copy() versus copy.deepcopy(), this entire category of subtle bugs disappeared from my code. It’s one of those Python fundamentals that feels small but touches almost every function you’ll ever write.

References

Total
0
Shares

Leave a Reply

Previous Post
Defining a function with multiple arguments in python

Defining a Function with Multiple Arguments in Python: Complete Parameter Types and Function Signature Guide

Next Post
Functional Programming in Python

Functional Programming in Python: Complete Map, Filter, Reduce, and Lambda Implementation Guide

Related Posts