No history yet

Names and Objects

Names, Not Boxes

Many programmers visualize variables as boxes that hold data. In some languages, this is a decent starting point. In Python, it's misleading. A more accurate model is to think of variables as labels or names pointing to objects that live in memory.

When you write x = 100, you are not putting the value 100 into a box named x. Instead, you are creating an integer object with the value 100, and then applying the name x to it. The name x now references that object. If you then write y = x, you are not copying the value from x's box to y's box. You are simply applying a second name, y, to the exact same integer object.

x = 100
y = x

# To prove they are the same object, we use the id() function.
# id() returns the memory address of an object.
print(id(x))
print(id(y))

# The output will show identical numbers, confirming
# x and y point to the same object in memory.

Mutation vs. Rebinding

This distinction between names and objects becomes critical when we introduce data types that can be changed. These are called mutable types. Lists and dictionaries are prime examples. In contrast, immutable types like integers, strings, and tuples cannot be changed once created.

Let's see what happens with a mutable list. If two names reference the same list, a change made through one name will be visible through the other. This is an in-place mutation.

# list_a and list_b point to the same list object
list_a = [1, 2, 3]
list_b = list_a

print(f"ID of list_a: {id(list_a)}")
print(f"ID of list_b: {id(list_b)}")

# Mutate the object using one of its names
list_b.append(4)

# The change is reflected in both names because
# they refer to the same, now-modified, object.
print(f"list_a is now: {list_a}") # Output: [1, 2, 3, 4]
print(f"list_b is now: {list_b}") # Output: [1, 2, 3, 4]

Now consider rebinding. This happens when we assign a name to a completely new object. The old object is unaffected. The name simply detaches from the old object and attaches to the new one.

list_a = [1, 2, 3]
list_b = list_a

# Rebind list_b to a new list object
list_b = [10, 20, 30]

# list_a still points to the original object
print(f"list_a is now: {list_a}") # Output: [1, 2, 3]
print(f"list_b is now: {list_b}") # Output: [10, 20, 30]

# Their IDs will now be different
print(f"ID of list_a: {id(list_a)}")
print(f"ID of list_b: {id(list_b)}")

Mutation changes the object. Rebinding changes the name.

Memory and References

So how does Python keep track of all these objects and know when it's safe to delete them? The standard version of Python, CPython, primarily uses a technique called reference counting. Every object in memory has a counter. This counter increments when a new name is pointed to it (y = x) and decrements when a name is rebound to something else (y = 200) or goes out of scope.

When an object's reference count drops to zero, it means no names are pointing to it anymore. The object is now unreachable, and Python's memory manager is free to reclaim its memory. This happens automatically behind the scenes.

This reference model also explains Python's argument passing behavior. It's not strictly "pass-by-value" or "pass-by-reference" in the way C++ or Java use those terms. A better term is pass-by-object-reference. When you call a function, a new name inside the function (the parameter) is created and bound to the same object that was passed in as an argument.

def modify_list(my_list):
    # The name 'my_list' is bound to the same object
    # as 'data' in the global scope.
    print(f"Inside function (before): {id(my_list)}")
    my_list.append(100) # This mutates the original object

data = [10, 20]
print(f"Outside function (before): {id(data)}")

modify_list(data)

print(f"Outside function (after): {id(data)}")
print(f"Data is now: {data}") # Output: [10, 20, 100]

This leads to a classic Python pitfall: using a mutable type as a default argument.

# The default list is created ONCE, when the function is defined.
def bad_append(element, to_list=[]):
    to_list.append(element)
    return to_list

list1 = bad_append(1)
print(list1) # Output: [1]

# The second call reuses the SAME list object from the definition.
list2 = bad_append(2)
print(list2) # Unexpected output: [1, 2]

# list1 is also affected because it's just another name for that same list.
print(list1) # Output: [1, 2]

The function's default list object is created only once, when Python first defines the function. Every subsequent call that relies on the default mutates the same list. The correct and safe way to handle this is to use None as the default and create a new list inside the function.

# The correct, safe pattern
def good_append(element, to_list=None):
    if to_list is None:
        to_list = [] # Create a new list each time
    to_list.append(element)
    return to_list

list1 = good_append(1)
print(list1) # Output: [1]

list2 = good_append(2)
print(list2) # Output: [2]

Ready to test your understanding of Python's memory model?

Quiz Questions 1/7

Which statement best describes the nature of variables in Python?

Quiz Questions 2/7

Consider the following code snippet:

a = [10, 20]
b = a
b.append(30)

After this code runs, what will be the value of the variable a?

Internalizing this model of names and objects is a huge step toward mastering Python. It clarifies subtle bugs, explains performance characteristics, and helps you write more predictable, efficient code.