DEV Community

Cover image for Python Thinks Different: What Actually Happens Inside Your Code (Visual Guide)
S M Tahosin
S M Tahosin

Posted on

Python Thinks Different: What Actually Happens Inside Your Code (Visual Guide)

Debunks the box analogy and aids beginners

Stop. Before you scroll past this, let me ask you something.

When you write x = 10 in Python, what do you think happens?

If your answer is "Python puts the number 10 inside a box called x," you are wrong. And honestly? That wrong mental model is the reason behind 90% of the confusing bugs beginners run into.

I spent a full week going down the rabbit hole of CPython source code, reading PEPs, and running weird experiments in my terminal. What I found completely changed how I write Python. And today, I want to share all of it with you.

Let's pull back the curtain on what Python is actually doing with your code.


The Biggest Lie You Were Told About Variables

Every beginner tutorial starts the same way. "Variables are like boxes. You put values in them."

Sounds logical. Feels right. But it is dead wrong in Python.

Variables are NOT boxes. They are labels.

In Python, variables are name tags, not boxes. When you write x = 10, Python does not stuff the number 10 into some container called x. Instead, Python creates an object (the integer 10) somewhere in memory, and then sticks a label called x on it.

Think about it like luggage tags at an airport. The tag does not hold your suitcase. It just tells you which suitcase to grab.

Let's prove it with actual code

x = 10
y = x

print(id(x))  # 140712834816848
print(id(y))  # 140712834816848  <- SAME address!

print(x is y)  # True
Enter fullscreen mode Exit fullscreen mode

Wait, x and y have the same memory address? Yes. Because y = x does not copy the value. It sticks another label on the same object.

Both x and y point to the same object in memory

This is the foundation of everything in Python. Get this right, and the rest falls into place.


Everything in Python is an Object. Seriously, Everything.

I know you have heard this before. But do you actually understand what it means?

In Python, the number 42 is not just a raw value floating around in memory. It is a full-blown object with:

  • A type (int)
  • A value (42)
  • A reference count (how many names point to it)
  • An id (its address in memory)

Check this out:

x = 42

print(type(x))           # <class 'int'>
print(x.__class__)       # <class 'int'>
print(isinstance(x, object))  # True

# Even functions are objects!
def greet():
    return "Hello"

print(type(greet))        # <class 'function'>
print(greet.__class__)    # <class 'function'>
print(isinstance(greet, object))  # True
Enter fullscreen mode Exit fullscreen mode

Functions, classes, modules, even None itself. All objects. Every single thing in Python lives as an object on the heap.

Quick quiz for you

What does this print?

print(type(type))
Enter fullscreen mode Exit fullscreen mode

Answer: <class 'type'>. The type of type is type itself. Mind bending, right? This is how Python bootstraps its entire type system.


Mutable vs Immutable: The Bug Factory

Here is where beginners lose hours of their lives debugging.

Mutable vs Immutable objects behave completely differently

Immutable objects (int, str, tuple, frozenset)

When you "change" an immutable object, Python does not modify the original. It creates a brand new object and re-points your variable to it.

x = 10
print(id(x))  # 140712834816848

x = x + 1
print(id(x))  # 140712834816880  <- DIFFERENT address!
Enter fullscreen mode Exit fullscreen mode

See that? The id changed. Python made a completely new object (11) and moved the x label to point at it. The old object (10) is still out there, untouched.

Mutable objects (list, dict, set)

This is where things get spicy. Mutable objects can be changed in place.

a = [1, 2, 3]
b = a  # b is just another label on the SAME list

b.append(4)

print(a)  # [1, 2, 3, 4]  <- Wait, I only changed b!
print(b)  # [1, 2, 3, 4]

print(a is b)  # True  <- They are literally the same object
Enter fullscreen mode Exit fullscreen mode

This is not a bug. This is by design. Both a and b are labels on the same list object. When you modify the list through b, you are modifying the same list that a is looking at.

The "default argument" trap

This one has bitten every Python developer at least once:

def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("apple"))   # ['apple']
print(add_item("banana"))  # ['apple', 'banana']  <- Huh?!
Enter fullscreen mode Exit fullscreen mode

The default list [] is created ONCE when the function is defined, not every time the function is called. Every call shares the same list object.

The fix:

def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item("apple"))   # ['apple']
print(add_item("banana"))  # ['banana']  <- Fixed!
Enter fullscreen mode Exit fullscreen mode

How Python Cleans Up After You: Garbage Collection

You never call free() or delete in Python. So who is cleaning up all those objects you stopped using?

Python uses two strategies working together:

Strategy 1: Reference Counting

Every object has a counter tracking how many names point to it. When that counter hits zero, the object gets deleted immediately.

Python Garbage Collection Process

import sys

a = "hello"
print(sys.getrefcount(a))  # 2 (one for 'a', one for the getrefcount argument)

b = a
print(sys.getrefcount(a))  # 3

del b
print(sys.getrefcount(a))  # 2

del a
# The "hello" object's refcount drops.
# If it hits 0, Python immediately frees the memory.
Enter fullscreen mode Exit fullscreen mode

Strategy 2: Cycle Detection (for circular references)

Reference counting alone cannot handle this:

# A points to B, B points to A. Both have refcount > 0 forever!
a = []
b = []
a.append(b)
b.append(a)

del a
del b
# Both objects still reference each other.
# Refcount never hits 0, but nobody can access them!
Enter fullscreen mode Exit fullscreen mode

This is where Python's generational garbage collector kicks in. It periodically scans for groups of objects that only reference each other with no outside connections, and cleans them up.

import gc

# You can see the garbage collector's thresholds
print(gc.get_threshold())  # (700, 10, 10)

# Generation 0: checked every 700 allocations
# Generation 1: checked every 10 Gen-0 collections
# Generation 2: checked every 10 Gen-1 collections

# Force a collection
collected = gc.collect()
print(f"Garbage collector freed {collected} objects")
Enter fullscreen mode Exit fullscreen mode

Python's Secret Speed Hack: Integer Caching

Here is something that blows people's minds when they first discover it.

Python pre-caches integers from -5 to 256

# Inside the cache range (-5 to 256)
a = 256
b = 256
print(a is b)  # True  <- Same object!

# Outside the cache range
a = 257
b = 257
print(a is b)  # False  <- Different objects!

# Negative numbers too
a = -5
b = -5
print(a is b)  # True

a = -6
b = -6
print(a is b)  # False
Enter fullscreen mode Exit fullscreen mode

When Python starts up, it pre-creates objects for every integer from -5 to 256 and reuses them everywhere. Why? Because these numbers show up constantly in real code (loop counters, array indices, boolean operations), and creating a fresh object every time would be slow.

This is why you should never use is to compare values. Use == instead. The is operator checks if two variables point to the same object in memory. The == operator checks if two variables have the same value.

# Always use == for value comparison
a = 257
b = 257
print(a == b)  # True  <- Correct way to compare values
print(a is b)  # False <- This checks identity, not equality!
Enter fullscreen mode Exit fullscreen mode

Python also caches strings (sometimes)

a = "hello"
b = "hello"
print(a is b)  # True  <- Python interned this string

a = "hello world!"
b = "hello world!"
print(a is b)  # False <- Strings with spaces/special chars? Not cached.
Enter fullscreen mode Exit fullscreen mode

Short, identifier-like strings get "interned" (cached) automatically. This helps speed up dictionary lookups and attribute access.


Shallow Copy vs Deep Copy: Know the Difference

This is hands down one of the most confusing topics for beginners, and one of the most common sources of bugs in production code.

Shallow Copy creates a new container but shares inner objects. Deep Copy clones everything.

Assignment (no copy at all)

original = [[1, 2], [3, 4]]
alias = original  # Just another name. No copy.

alias[0][0] = 99
print(original)  # [[99, 2], [3, 4]]  <- Changed!
Enter fullscreen mode Exit fullscreen mode

Shallow Copy

import copy

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

# The outer list is new
print(original is shallow)  # False

# But the inner lists are SHARED
print(original[0] is shallow[0])  # True

shallow[0][0] = 99
print(original)  # [[99, 2], [3, 4]]  <- Still linked!
Enter fullscreen mode Exit fullscreen mode

Deep Copy

import copy

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

# Everything is new, at every level
print(original is deep)        # False
print(original[0] is deep[0])  # False

deep[0][0] = 99
print(original)  # [[1, 2], [3, 4]]  <- Safe!
Enter fullscreen mode Exit fullscreen mode

Quick decision guide

Situation What to use
I just need another name for the same data alias = original
I need a new list, but inner data can be shared copy.copy() or list() or [:]
I need a completely independent clone copy.deepcopy()

The __slots__ Trick: Saving Memory Like a Pro

By default, every Python object stores its attributes in a dictionary (__dict__). That dictionary takes up a lot of memory.

If you know exactly what attributes your class will have, you can use __slots__ to skip the dictionary entirely.

import sys

class PlayerWithDict:
    def __init__(self, name, score):
        self.name = name
        self.score = score

class PlayerWithSlots:
    __slots__ = ['name', 'score']

    def __init__(self, name, score):
        self.name = name
        self.score = score

# Let's compare memory usage
player_dict = PlayerWithDict("Tahosin", 100)
player_slots = PlayerWithSlots("Tahosin", 100)

print(sys.getsizeof(player_dict.__dict__))  # 296 bytes (the dict alone!)
print(sys.getsizeof(player_slots))           # 48 bytes (total!)
Enter fullscreen mode Exit fullscreen mode

When you are creating millions of objects (game entities, data records, API responses), the difference adds up fast.

# Real-world impact: Creating 1 million player objects
import tracemalloc

tracemalloc.start()

# Without __slots__
players_dict = [PlayerWithDict(f"Player_{i}", i) for i in range(1_000_000)]
snapshot1 = tracemalloc.take_snapshot()

# Clear and restart
del players_dict
tracemalloc.stop()
tracemalloc.start()

# With __slots__
players_slots = [PlayerWithSlots(f"Player_{i}", i) for i in range(1_000_000)]
snapshot2 = tracemalloc.take_snapshot()

# The __slots__ version uses roughly 40-50% less memory!
Enter fullscreen mode Exit fullscreen mode

Pass by What? Understanding Function Arguments

"Is Python pass by value or pass by reference?"

Neither. Python uses pass by object reference (sometimes called "pass by assignment").

When you pass an argument to a function, Python does not copy the value (pass by value) and does not give you a direct pointer to the variable (pass by reference). Instead, it creates a new local name that points to the same object.

def modify(data):
    # 'data' is a new name pointing to the SAME list
    data.append(4)  # Modifies the original object

numbers = [1, 2, 3]
modify(numbers)
print(numbers)  # [1, 2, 3, 4]  <- Changed!
Enter fullscreen mode Exit fullscreen mode
def reassign(data):
    # This creates a BRAND NEW list and points 'data' to it
    data = [10, 20, 30]  # Only rebinds the local name
    print(f"Inside function: {data}")

numbers = [1, 2, 3]
reassign(numbers)
print(f"Outside function: {numbers}")  # [1, 2, 3]  <- Unchanged!
Enter fullscreen mode Exit fullscreen mode

Here is the mental model:

Before function call:
  numbers -----> [1, 2, 3]

Inside modify():
  numbers -----> [1, 2, 3]    <- Both point to same list
  data    -----/

Inside reassign():
  numbers -----> [1, 2, 3]    <- numbers still points here
  data    -----> [10, 20, 30] <- data now points to a NEW list
Enter fullscreen mode Exit fullscreen mode

The rule is simple: modifying the object through the new name affects the original. Rebinding the new name to a different object does not.


Bonus: Prove It All With id() and sys.getrefcount()

Here is a complete experiment you can run yourself to see all of this in action:

import sys

print("=" * 50)
print("EXPERIMENT 1: Variables are labels")
print("=" * 50)

x = [1, 2, 3]
y = x
print(f"id(x) = {id(x)}")
print(f"id(y) = {id(y)}")
print(f"Same object? {x is y}")
print(f"Ref count: {sys.getrefcount(x)}")
print()

print("=" * 50)
print("EXPERIMENT 2: Immutable rebinding")
print("=" * 50)

a = 42
print(f"Before: id(a) = {id(a)}")
a += 1
print(f"After a += 1: id(a) = {id(a)}")
print(f"Value of a: {a}")
print("The id changed because a new object was created!")
print()

print("=" * 50)
print("EXPERIMENT 3: Integer caching")
print("=" * 50)

for num in [0, 100, 256, 257, -5, -6]:
    a = num
    b = num
    print(f"  {num:>4}: a is b = {a is b}")
print()

print("=" * 50)
print("EXPERIMENT 4: String interning")
print("=" * 50)

s1 = "hello"
s2 = "hello"
s3 = "hello world"
s4 = "hello world"
print(f"  'hello': s1 is s2 = {s1 is s2}")
print(f"  'hello world': s3 is s4 = {s3 is s4}")
Enter fullscreen mode Exit fullscreen mode

Run this on your own machine. Watch the outputs. Play with it. Change things. Break things. That is how you really learn this stuff.


Cheat Sheet: The Rules of Python's Object Model

Concept What Python actually does
x = 10 Creates an int object (10), binds the name x to it
y = x Binds the name y to the same object x points to
x = x + 1 Creates a NEW int object (11), rebinds x to it
a.append(4) Modifies the list object in place, all names see the change
del x Removes the name x, decrements refcount of its object
x == y Compares values (use this!)
x is y Compares identity / memory address (rarely use this)
copy.copy() New outer container, shared inner objects
copy.deepcopy() Completely independent clone at every level

Wrapping Up

Python looks simple on the surface. x = 10 feels like the most basic thing in the world. But underneath, there is a sophisticated system of objects, references, and memory management working together.

Understanding these internals does not make you a "theoretical" programmer. It makes you a better practical programmer because:

  • You will stop writing bugs caused by shared mutable objects
  • You will know when to copy data and when not to
  • You will write more memory-efficient code using __slots__
  • You will actually understand error messages about mutability
  • You will pass Python interviews with confidence

The next time someone says "variables are boxes," you will know better.

Now go run those experiments. Break something. That is how this stuff sticks.


What concept surprised you the most? Have you ever been bitten by the mutable default argument bug? I would love to hear your stories in the comments.

Top comments (14)

Collapse
 
henry786 profile image
Henry •

That shallow vs deep copy distinction is super relevant for us at The Printing World. We deal with complex nestings of custom box dimensions, print layers, and dielines, so accidentally copying references in our order script could totally wreck production!

Collapse
 
smtahosin profile image
S M Tahosin •

Absolutely! That’s a great real-world example. With nested box dimensions, layers, and dielines, a shared reference could easily lead to unexpected changes in production. Glad the shallow vs deep copy distinction was useful!

Collapse
 
sameerqaisar17 profile image
Sameer Qaiser •

The "variables are labels, not boxes" framing is the thing I wish I'd read two weeks ago.

I'm a beginner — I started learning Python about two weeks ago and I've been writing beginner tutorials about it. And the exact mental model you're describing is what tripped me up.

When I first learned a = [1, 2, 3] and b = a, I assumed b was a copy. Then I did b.append(4) and a changed too. I spent a solid hour convinced Python was broken.

It wasn't broken. I was.

The id() experiment is what fixed it for me. Seeing the same memory address print twice made the "label" idea click in a way that no amount of explanation could.

I haven't touched the advanced stuff yet — slots, garbage collection, deep copy vs shallow copy. I'm still at try/except and file handling. But the mental model you just gave me makes me feel like those things will actually make sense when I get there, instead of feeling like magic.

Saving this. Running the experiments tonight

Collapse
 
smtahosin profile image
S M Tahosin •

Haha, “Python was broken” is honestly a very relatable beginner moment 😄. Glad the id() experiment made the label idea click for you. Once that mental model is clear, a lot of Python’s “weird” behavior starts feeling much less magical. Keep going, you’re building the right foundation!

Collapse
 
naveen_alavilli profile image
Naveen Alavilli •

Since you're at file handling right now — the "with open(...) as f" pattern you're using is actually the same object model paying off early. f is a label on a file object, and the with block guarantees Python calls the file's cleanup the moment the block ends, whether it exits normally or via an exception, because the object's reference count doesn't have to hit zero for that to happen — the context manager forces it deterministically. That's the practical reason "always use with, don't call open() and close() by hand" isn't just a style rule: forgetting the close() leaves the file object alive and the handle open for however long reference counting takes to catch up, which in a long-running program can be a while. Same labels-not-boxes idea, just showing up as "why does this specific syntax exist" instead of a list-mutation surprise.

Collapse
 
doushabao profile image
Doushabao •

Nice visualization of Python internals! I use Python extensively for building API backends and this kind of understanding really helps with optimization. Curious — have you looked at how Python's GIL affects API server throughput under concurrent requests?

Collapse
 
erkupl profile image
erkupl •

a@kyisaiah47 It doesn't try to find cycles at all, which is the clever part. The collector walks every container object it tracks and, for each one, subtracts from a temporary copy of its refcount every reference that comes from another tracked container. Whatever is left over is the number of references coming from outside the tracked set: a local variable, a module global, the stack. Objects with a leftover count above zero are reachable roots, and anything reachable from them is marked alive. Everything still sitting at zero is only held up by other garbage, cycle or not, and gets collected. So a reachable graph with a cycle survives because at least one of its nodes still has an external reference after the subtraction.

Collapse
 
smtahosin profile image
S M Tahosin •

Exactly! That’s the part I find really interesting too. It’s less about “detecting a cycle” and more about figuring out whether anything outside the tracked graph can still reach those objects. Once you see it that way, cyclic GC becomes much easier to reason about.

Collapse
 
onizuka profile image
Onizuka •

The id(x) == id(y) example is the right way to teach this — I've debugged code from three different devs who got burned by mutable defaults in function arguments for the exact same reason. They thought def f(items=[]) created a fresh list each call. It doesn't. It's one list object, one address, every call shares it. Run id(items) inside that function across two calls and you'll see the same address both times. That single experiment would've saved me about 4 hours one Saturday.

Collapse
 
botsailorofficial profile image
BotSailor •

This is the kind of Python explanation I wish more beginners came across early. The “variables are labels, not boxes” mental model makes so many confusing things—mutability, assignment, function arguments, and shallow copies—suddenly feel much less mysterious.

I especially liked that you didn’t stop at explaining the concepts and actually encouraged people to run the experiments themselves. Seeing id() values change or watching two names affect the same list is one of those things that sticks much longer than reading another definition.

The mutable default argument example also brought back a few debugging memories 😅. It’s amazing how a tiny piece of Python syntax can teach you so much about how the language actually works.

Really enjoyed the visual/experimental approach here. “Break things and see what happens” is honestly one of the best ways to build a solid mental model of programming.

Collapse
 
kyisaiah47 profile image
Isaiah Kim •

How does the cycle collector distinguish an unreachable cycle from a reachable object graph that happens to contain a cycle?

Collapse
 
smtahosin profile image
S M Tahosin •

Good question! It doesn’t really look for the cycle itself. It checks whether the objects in that cycle still have references coming from outside the tracked group. If an external reference remains, the graph is reachable and survives. If not, the objects are only keeping each other alive, so they can be collected.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.