The Lie You Were Told
If you've taken an introductory programming course, you've heard this:
"A variable is like a box. You put a value inside it."
For C or Java, that's close enough. For Python, this analogy will actively create bugs in your code.
I've seen intermediate developers with years of experience still confused by "mutation bugs"—where changing one variable mysteriously changes another. Every single time, it traces back to the wrong mental model.
Let me give you a better one.
The Sticky Note Model
In Python, variables are not containers. Variables are names (labels) that point to objects in memory.
Think of Python objects (numbers, lists, strings) as balloons floating in memory.
Think of variables as sticky notes attached to those balloons with strings.
When you write:
x = 10
You are NOT putting "10" inside an "x" box.
You are:
- Creating an integer object
10somewhere in memory - Attaching a sticky note labeled
xto it
This distinction matters more than any syntax you'll learn.
Why This Matters: The Aliasing Trap
Here's where the "box" model actively hurts you.
a = [1, 2, 3]
b = a
If variables were boxes: You would have two boxes. Each contains a list [1, 2, 3]. They're independent copies.
In the Sticky Note model: You just attached a second label (b) to the same list object.
Now watch what happens:
b.append(4)
print(a)
# Output: [1, 2, 3, 4]
Wait—we only modified b. Why did a change?
Because a and b are not two lists. They're two names for one list.
If you're holding the "box" model in your head, this looks like a bug. You'll spend 30 minutes wondering why your data is corrupted.
If you understand the "sticky note" model, it's obvious.
Proving It: The id() Function
Python gives you a tool to see the memory address of any object: id().
a = [1, 2, 3]
b = a
print(f"id(a) = {id(a)}") # e.g., 4344278784
print(f"id(b) = {id(b)}") # e.g., 4344278784 (same!)
print(f"Same object? {a is b}") # True
The is keyword checks identity (same object), not equality (same value).
c = [1, 2, 3] # A NEW list with the same contents
print(f"a == c: {a == c}") # True (same value)
print(f"a is c: {a is c}") # False (different objects)
But Wait—Why Don't Integers Do This?
You might try this and think I'm wrong:
x = 5
y = x
x = 10
print(y) # Still 5!
Here's the key: integers are immutable. You can't change the number 5 into the number 10. When you write x = 10, you're not modifying the object—you're moving the sticky note to a different object.
Lists are mutable. When you call b.append(4), you're modifying the existing object, not creating a new one.
| Type | Mutable? | Assignment creates new object? |
|---|---|---|
| int, float, str, tuple | Immutable | Yes (reassignment moves the label) |
| list, dict, set | Mutable | No (modification affects all labels) |
This is the most important table in Python.
Real-World Bug: Function Side Effects
Here's a bug I've seen in production code:
def add_default_values(config):
config["timeout"] = 30
config["retries"] = 3
return config
DEFAULT_CONFIG = {"api_key": "secret"}
# First API call
config1 = add_default_values(DEFAULT_CONFIG)
# Second API call - oops!
config2 = add_default_values(DEFAULT_CONFIG)
print(DEFAULT_CONFIG)
# {'api_key': 'secret', 'timeout': 30, 'retries': 3}
The developer expected DEFAULT_CONFIG to stay default. But because dictionaries are mutable and passed by reference, the function modified the original object.
The fix: Use .copy() or dict() to create actual copies when needed.
def add_default_values(config):
new_config = config.copy() # Create a real copy
new_config["timeout"] = 30
new_config["retries"] = 3
return new_config
Quick Reference: Identity vs Equality
| Check | Operator | Question |
|---|---|---|
| Identity | is |
Are these the same object in memory? |
| Equality | == |
Do these objects have the same value? |
Rule of thumb:
- Use
isforNone:if result is None: - Use
==for everything else:if x == 5:
Key Takeaways
Variables are labels, not containers. They point to objects; they don't hold them.
Assignment binds a name to an object. It doesn't copy data.
Multiple names can point to the same object. This is called aliasing.
Mutable objects (list, dict, set) can be changed in place. Immutable objects (int, str, tuple) cannot.
Use
id()andiswhen you need to understand what's happening in memory.
What's Next?
This article is adapted from Chapter 6 of "Zero to AI Engineer: Python Foundations."
I'm writing this book in public—subscribe on Substack to follow along, get early chapters, and get notified when it launches.
If this helped clarify Python's memory model, drop a 🧡 and share it with someone still stuck on the "box" analogy!
Top comments (3)
When dealing with these nested configurations, do you usually recommend using .copy() or is it better to go straight for deepcopy to avoid those hidden pointer issues?
Thanks for the clear breakdown!
It depends on how deep your nesting goes.
.copy()creates a shallow copy—it duplicates the top-level dictionary, but any nested objects (lists, dicts) still point to the originals. So if your config looks like this:The nested list is still shared.
deepcopyrecursively copies everything, so you get a truly independent structure:My rule of thumb:
.copy()is fine and faster.deepcopy. The performance cost is negligible for config-sized data, and it saves you from subtle bugs.When in doubt,
deepcopyis the safer default. You can always optimize later if profiling shows it matters (it rarely does for configs).Thank you for the clarification