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)

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 (0)