← Python Code Memory & Data Structures
Browse Python Concepts

Mutable vs Immutable — Why It Matters More Than You Think

Mental Model

Think of a mutable default argument as a shared resource that belongs to the function itself, created only once when the function is defined. Every call that uses the default argument accesses and potentially modifies that same shared resource.

Rule: Always treat shared collections as read-only or copy them explicitly to prevent unintended mutations from leaking across scope boundaries.

The Setup

You are implementing an audit logger for an API endpoint that tracks a user's permissions. To save memory, you attempt to cache and share permission configurations across requests, updating them when a specific user is assigned elevated privileges.

What Does This Print?

Broken code
Python
# Shared global configurations
DEFAULT_PERMISSIONS = {"read": True, "write": False}

class Session:
    def __init__(self, username, perms=DEFAULT_PERMISSIONS):
        self.username = username
        self.perms = perms

    def elevate(self):
        self.perms["write"] = True

session_a = Session("alice")
session_b = Session("bob")

session_a.elevate()
print(f"Alice permissions: {session_a.perms}")
print(f"Bob permissions: {session_b.perms}")
Predict what Bob's permissions are after Alice's session elevates its permissions. Does Bob remain restricted?

The Output

What actually happens
Alice permissions: {'read': True, 'write': True} Bob permissions: {'read': True, 'write': True}

Bob's permissions were elevated alongside Alice's because both objects share a reference to the same mutable dictionary DEFAULT_PERMISSIONS. Mutating the dictionary in-place affects all references pointing to that location in memory.

Why Python Does This

In Python, variables are labels bound to memory locations, not buckets containing data. Immutable types (tuples, strings, integers) cannot be altered after creation; operations on them always return a new object with a new memory address. Mutable types (dicts, lists, sets) can alter their internal state in-place without changing their memory address (id()). When you assign a dictionary to multiple instances, they all store a reference to the exact same memory location, making mutations globally visible to all holders of that reference.

The Fix

Corrected pattern
Python
# Solution: Use immutable frozenset/tuples or return a copy on initialization
DEFAULT_PERMISSIONS = frozenset(["read"])

class Session:
    def __init__(self, username, perms=None):
        self.username = username
        # Create a new mutable container if none is provided, avoiding shared references
        self.perms = dict(read=True, write=False) if perms is None else dict(perms)

    def elevate(self):
        self.perms["write"] = True

session_a = Session("alice")
session_b = Session("bob")
session_a.elevate()

By using None as the default and then checking for None inside the function to create a new mutable object, a fresh, independent object is instantiated for each call that doesn't explicitly provide one. This ensures no unintended sharing.

How This Fails in Real Systems

An e-commerce billing service used a default dictionary for shipping details across orders. When a customer updated their shipping address during checkout, the system mutated the shared default dict. Consequently, subsequent orders for completely unrelated customers were routed to the first buyer's address, leading to a critical data leak and logistics failure.

Key Takeaway

Always treat shared collections as read-only or copy them explicitly to prevent unintended mutations from leaking across scope boundaries.
Common mistake: Developers incorrectly assume that default argument values are re-evaluated or copied for each function call, rather than being evaluated once when the function is defined.