When I first started writing Python code, I’d often find myself writing this “hidden mistake” - passing a mutable object as a parameter surfaces bugs you wouldn’t expect.

Let’s suppose you’re validating sign-ups from a form. Your code might look something like this:

def validate_signup(form_data, errors = []):
    if not form_data.get("email"):
        errors.append("email is required")
    if not form_data.get("password"):
        errors.append("password is required")
    return errors

While this might look like error-free code, the “hidden bug” exists:

signup_a = validate_signup({"email": "[email protected]"})
signup_b = validate_signup({"password": "hunter2"})

print(signup_a)   # ['password is required', 'email is required']
print(signup_b)   # same list, same wrong result

Here, 2 completely unrelated signups end up sharing and corrupting the same error list. You’d expect signup_a to be ['password is required'] and signup_b to be ['email is required'].

Based upon the output, you might be tempted to think it’s a race condition or a caching problem, but really it’s the errors = [] argument that’s the issue.

Why this happens

Default argument values are evaluated once, when the function is defined - not on every call. A mutable default like a list, dict, or set gets created a single time, and every call that doesn't pass its own value shares that same object.

You can prove it with id():

def validate_signup(form_data, errors = []):
    print(id(errors))   # same number, every single call
    ...

Every invocation prints the identical object id - it's not two similar-looking lists, it's the literal same list, being handed to every caller.

This bites people just as hard outside of web forms:

def create_user(name, roles = []):
    roles.append("default_user")
    return {"name": name, "roles": roles}

Every user you create quietly shares - and pollutes - the same roles list. You'll get a bug report like "user A's roles are showing up on user B" and it'll look like a mystery until you find this.

The fix

Use None as the default, then build the mutable object inside the function body, where it runs fresh on every call:

def validate_signup(form_data, errors = None):
    if errors is None:
        errors = [] # Declare it here instead
    if not form_data.get("email"):
        errors.append("email is required")
    if not form_data.get("password"):
        errors.append("password is required")
    return errors

signup_a = validate_signup({"email": "[email protected]"})
signup_b = validate_signup({"password": "hunter2"})

# Correct outputs
print(signup_a)   # ['password is required']
print(signup_b)   # ['email is required']

Same pattern for dicts and sets:

def add_entry(key, value, data = None):
    if data is None:
        data = {}
    data[key] = value
    return data

The one exception: caching

Mutable defaults aren't always a bug. A simple memoization cache is a legitimate, if rare, use of the pattern:

def expensive_calc(x, _cache = {}):
    if x not in _cache:
        _cache[x] = x ** 2  # pretend this is expensive
    return _cache[x]

Some experienced Python devs do this on purpose. But it's rare enough - and confusing enough to read - that linters like ruff and pylint flag it by default. If you keep one, leave a comment explaining why.

Rule of thumb: if your default argument is a list, dict, set, or any other mutable object, it's almost always a bug unless you've deliberately chosen it as a cache and documented it. The official Python FAQ covers the same gotcha if you want the language spec's own take.

If you liked digging into how arguments get bound, you'll probably enjoy our partial functions tutorial - it's the flip side of this problem, pre-binding arguments on purpose instead of accidentally sharing them.

Ever been burned by this one? Hit reply and tell me about it, or tell me what other "looks simple, isn't" Python gotcha you want covered next.

Happy coding!

📧 Join the Python Snacks Newsletter! 🐍

Want even more Python-related content that’s useful? Here’s 3 reasons why you should subscribe the Python Snacks newsletter:

  1. Get Ahead in Python with bite-sized Python tips and tricks delivered straight to your inbox, like the one above.

  2. Exclusive Subscriber Perks: Receive a curated selection of up to 6 high-impact Python resources, tips, and exclusive insights with each email.

  3. Get Smarter with Python in under 5 minutes. Your next Python breakthrough could just an email away.

You can unsubscribe at any time.

Interested in starting a newsletter or a blog?

Do you have a wealth of knowledge and insights to share with the world? Starting your own newsletter or blog is an excellent way to establish yourself as an authority in your field, connect with a like-minded community, and open up new opportunities.

If TikTok, Twitter, Facebook, or other social media platforms were to get banned, you’d lose all your followers. This is why you should start a newsletter: you own your audience.

This article may contain affiliate links. Affiliate links come at no cost to you and support the costs of this blog. Should you purchase a product/service from an affiliate link, it will come at no additional cost to you.

Reply

Avatar

or to participate

Keep Reading