In Python, assignment does not copy an object’s value; instead, it binds a name to a reference of that object in memory. This fundamental concept is central to understanding Python’s object model and avoiding common bugs. When you write x = 10, you are not placing the value 10 into a box labeled x. Rather, you are creating an object 10 (if it doesn’t already exist) and attaching the name tag x to it. The variable x is a reference, or a pointer, to the object.

This reference-based model has profound implications, especially when dealing with mutable objects like lists, dictionaries, and sets. Multiple names can be bound to the same object, and mutations performed through one name will be visible through all other names bound to that same object.

The id() Function and Identity

To understand that assignment creates references, you can use the built-in id() function, which returns the unique identifier (representing the memory address) of an object. If two variables have the same id(), they are references to the exact same object.

list_a = [1, 2, 3]
list_b = list_a  # Assigning list_b to the same object list_a references

print(id(list_a))  # e.g., 140241001123136
print(id(list_b))  # Outputs the same number: 140241001123136

# Modifying the object via one name
list_a.append(4)
print(list_b)  # Output: [1, 2, 3, 4] - The change is reflected in list_b

Mutable vs. Immutable Objects

The practical impact of assignment semantics depends heavily on an object’s mutability.

  • Immutable Objects: Types like int, float, str, tuple, frozenset, and bool cannot be changed after creation. Assignment with these types often feels like a copy because operations create new objects. When you perform an operation like x += 1 on an integer, it creates a new integer object and rebinds the name x to it. Other names bound to the old object remain unaffected.
x = 100
y = x  # Both x and y reference the same object (100)
print(id(x) == id(y))  # Output: True

x += 1  # This operation creates a NEW integer object (101)
print(x)  # Output: 101
print(y)  # Output: 100 - y is still bound to the original object
print(id(x) == id(y))  # Output: False - They are now different objects
  • Mutable Objects: Types like list, dict, set, and custom classes can be changed in-place. Operations like append() or update modify the existing object without creating a new one. If multiple names are bound to this object, all will see the change, which is a very common source of bugs.
def add_greeting(items):
    items.append('hello')  # Modifies the object in-place

my_list = ['a', 'b']
add_greeting(my_list)
print(my_list)  # Output: ['a', 'b', 'hello'] - The original list was modified

Explicit Shallow and Deep Copying

To avoid the unintended side effects of shared references to mutable objects, you must explicitly create copies. The copy module provides the tools to do this correctly.

  • Shallow Copy (copy.copy()): Creates a new object but populates it with references to the same elements found in the original. This is sufficient for a simple list of integers but dangerous for a list containing other mutable objects.
import copy

original = [1, [2, 3], 4]
shallow_copied = copy.copy(original)

# The outer list is a new object
original.append(5)
print(original)     # Output: [1, [2, 3], 4, 5]
print(shallow_copied) # Output: [1, [2, 3], 4] - The new list wasn't affected

# But the inner list is still a shared reference!
original[1].append(99)
print(original)     # Output: [1, [2, 3, 99], 4, 5]
print(shallow_copied) # Output: [1, [2, 3, 99], 4] - The change is reflected!
  • Deep Copy (copy.deepcopy()): Creates a new object and recursively copies all objects found within it. This creates a completely independent clone with no shared references.
import copy

original = [1, [2, 3], 4]
deep_copied = copy.deepcopy(original)

original[1].append(99)
print(original)     # Output: [1, [2, 3, 99], 4]
print(deep_copied)  # Output: [1, [2, 3], 4] - The copy is completely unaffected

Best Practices and Common Pitfalls

  1. Function Arguments: Always be aware that you are passing references to objects. If you pass a mutable object to a function and that function modifies it, you are modifying the original. If this is not desired, consider making a copy inside the function (copy.copy() or copy.deepcopy() depending on the structure).
  2. Default Mutable Arguments: A classic Python pitfall. A default argument is evaluated only once, when the function is defined. This means a single mutable object is created and shared across all function calls that use the default.
# DANGEROUS:
def add_to_list(item, target_list=[]): # The same list object is used every time
    target_list.append(item)
    return target_list

print(add_to_list(1))  # Output: [1]
print(add_to_list(2))  # Output: [1, 2] - It retained the value from the first call!

# SAFE:
def add_to_list_safe(item, target_list=None):
    if target_list is None:
        target_list = []  # A new list is created each time the default is needed
    target_list.append(item)
    return target_list
  1. Use is for Identity Checks: The is keyword checks if two variables reference the exact same object (id(a) == id(b)). Use it to check for None, or in rare cases where object identity matters. Use == to check for logical equality.