DEV Community

Samuel Ochaba
Samuel Ochaba

Posted on

Why I Teach Variables as "Sticky Notes", Not Boxes (And Why It Will Save You Hours of Debugging)

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
Enter fullscreen mode Exit fullscreen mode

You are NOT putting "10" inside an "x" box.

You are:

  1. Creating an integer object 10 somewhere in memory
  2. Attaching a sticky note labeled x to 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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 is for None: if result is None:
  • Use == for everything else: if x == 5:

Key Takeaways

  1. Variables are labels, not containers. They point to objects; they don't hold them.

  2. Assignment binds a name to an object. It doesn't copy data.

  3. Multiple names can point to the same object. This is called aliasing.

  4. Mutable objects (list, dict, set) can be changed in place. Immutable objects (int, str, tuple) cannot.

  5. Use id() and is when 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)

Collapse
 
maame-codes profile image
Maame Afua A. P. Fordjour

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!

Collapse
 
samuel_ochaba_eb9c875fa89 profile image
Samuel Ochaba

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:

config = {
    "timeout": 30,
    "endpoints": ["api1", "api2"]  # nested list
}

new_config = config.copy()
new_config["endpoints"].append("api3")

print(config["endpoints"])  # ['api1', 'api2', 'api3'] — oops!
Enter fullscreen mode Exit fullscreen mode

The nested list is still shared.

deepcopy recursively copies everything, so you get a truly independent structure:

from copy import deepcopy

new_config = deepcopy(config)
new_config["endpoints"].append("api3")

print(config["endpoints"])  # ['api1', 'api2'] — safe!
Enter fullscreen mode Exit fullscreen mode

My rule of thumb:

  • Flat config (no nested dicts/lists)? .copy() is fine and faster.
  • Nested structures? Go straight to deepcopy. The performance cost is negligible for config-sized data, and it saves you from subtle bugs.

When in doubt, deepcopy is the safer default. You can always optimize later if profiling shows it matters (it rarely does for configs).

Collapse
 
maame-codes profile image
Maame Afua A. P. Fordjour

Thank you for the clarification