DEV Community

Cover image for Beyond the Label: How Python Variables Really Work with Memory
Aaron Rose
Aaron Rose

Posted on

Beyond the Label: How Python Variables Really Work with Memory

You've mastered the basics: variables are labels, not boxes. You know about is vs. ==. Now, let's pull back the curtain further and see what happens when you deal with more complex data structures. This knowledge is key to avoiding some of the most common and frustrating bugs.

1. Everything is an Object (And Has an ID)

In Python, everything is an object. Integers, strings, functions, modules, even classes themselves—they all live in memory and have three things:
1.  Identity: A unique, constant number (its identity) that acts like a memory address in CPython. You see this with the id() function. This number is guaranteed to be unique for the object's lifetime.
2.  Type: What kind of object it is (e.g., int, str, list).
3.  Value: The actual data it holds.

The id() is the "home address" of the object. The is keyword simply compares these IDs.

a = [1, 2, 3]  # Python creates a list object, gives it an ID, and tags it `a`
print(id(a))    # Prints a long number, e.g., 139936863411456

b = a           # This attaches a new tag `b` to the SAME object (same ID)
print(id(b))    # Same number as above!
print(a is b)   # True, because their IDs are identical.
Enter fullscreen mode Exit fullscreen mode

This relationship can be visualized simply:

a --> [1, 2, 3] <-- b
Enter fullscreen mode Exit fullscreen mode

Both variables are references (labels) pointing to the same single list object in memory.


2. Assignment vs. Shallow Copy vs. Deep Copy

This is the heart of the matter. The confusion between these three operations is a classic rite of passage.

  • Assignment (=): This only creates a new label (variable) for the existing object. No new object is created. You now have two labels pointing to the same data. Change the data through one label, and it changes for the other.

  • Shallow Copy: Creates a new outer object, but instead of creating copies of the inner objects, it just copies the references to them. It's like buying a new binder (new_list) and putting photocopies of the old binder's table of contents inside. The chapters (the inner objects) themselves are still the same. You can create a shallow copy with .copy(), list(), or slicing original_list[:].

  • Deep Copy: Creates a new outer object and then recursively creates new copies of every object found within the original. It's a complete duplicate, with no connection to the original.

Let's see the crucial difference with a list containing another list (a nested structure):

import copy

original = [1, 2, [3, 4]] # A list within a list

# Assignment
assigned = original

# Shallow Copy (using .copy() - list(original) or original[:] work the same)
shallow_copied = original.copy()

# Deep Copy
deep_copied = copy.deepcopy(original)

# Now, let's change the inner list from the original
original[2].append(5)

print("Original:", original)   # [1, 2, [3, 4, 5]]
print("Assigned:", assigned)   # [1, 2, [3, 4, 5]] (changed - same object)
print("Shallow Copy:", shallow_copied) # [1, 2, [3, 4, 5]] 😲 (changed! The inner list is shared)
print("Deep Copy:", deep_copied)      # [1, 2, [3, 4]]    (unchanged - truly independent)
Enter fullscreen mode Exit fullscreen mode

The Shallow Copy Surprise: This is the key takeaway. The outer list shallow_copied is new, so appending to it wouldn't affect original. But the inner list [3, 4] is the same object in both lists. Modifying it through one variable affects the other.


3. Function Arguments are "Passed by Object Reference"

This concept ties everything together. People often ask, "Is Python pass-by-reference or pass-by-value?" The most accurate answer is: It's "pass-by-object-reference."

When you call a function, a new label (the parameter name) is assigned to the same object that was passed in. The same rules apply. The key is to understand the difference between modifying an object and re-binding a label.

  • Modifying a mutable object in-place (e.g., using .append(), .update()) will be visible outside the function.
def append_to_list(some_list):
    some_list.append("oops") # This modifies the original object itself.
    print("Inside function:", some_list)

my_list = ["hello"]
append_to_list(my_list) # Output: Inside function: ['hello', 'oops']
print("Outside function:", my_list) # Output: Outside function: ['hello', 'oops'] 😲
Enter fullscreen mode Exit fullscreen mode
  • Re-binding a label inside a function (using =) only changes what that local label points to. It has no effect on the original outside variable.
def reassign_list(some_list):
    some_list = ["a", "new", "list"] # Re-binds the local `some_list` label to a new object.
    print("Inside function (after re-assignment):", some_list)

my_list = ["hello"]
reassign_list(my_list) # Output: Inside function (after re-assignment): ['a', 'new', 'list']
print("Outside function:", my_list) # Output: Outside function: ['hello'] ✅ (Unaffected!)
Enter fullscreen mode Exit fullscreen mode

Your Mental Model Checklist

Before you write a line of code, ask yourself:
1.  What is the data type? Is it mutable (list, dict, set) or immutable (int, str, tuple)?
2.  What operation am I performing? Am I assigning (=), making a shallow copy (.copy(), list(), [:]), or a deep copy (copy.deepcopy())?
3.  If it's a function, what will happen inside? Will it modify the object I pass (in-place change) or just reassign the parameter label (no outside effect)?

Understanding this moves you from writing code that works by accident to writing code that works by design. You stop fearing side effects and start controlling them.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)