Python Internals

Understand CPython internals — the GIL, memory management, reference counting, bytecode, and the import system.

Advanced 35 min read 🐍 Python

Everything is an Object

This isn't just a slogan — it's literally true. In CPython (the standard Python implementation), every value is a C struct on the heap. Integers, strings, functions, classes, modules, even None and True — they're all objects with an identity (id()), a type (type()), and a reference count.

x = 42
print(f"Value: {x}")
print(f"Type:  {type(x)}")
print(f"ID:    {id(x)}")     # Memory address
print(f"Size:  {x.__sizeof__()} bytes")

# Functions are objects too
def greet():
    """Say hello."""
    return "Hi!"

print(f"\nFunction type: {type(greet)}")
print(f"Function name: {greet.__name__}")
print(f"Function doc:  {greet.__doc__}")
print(f"Function code: {greet.__code__.co_varnames}")
Output
Value: 42
Type:  <class 'int'>
ID:    140234866048336
Size:  28 bytes

Function type: <class 'function'>
Function name: greet
Function doc:  Say hello.
Function code: ()

The GIL (Global Interpreter Lock)

The GIL is CPython's most misunderstood feature. It's a mutex that allows only one thread to execute Python bytecode at a time. This exists because CPython's memory management (reference counting) is not thread-safe.

What GIL Does

Prevents multiple threads from running Python code simultaneously. Only one thread holds the GIL at any time.

What GIL Doesn't Do

It does NOT prevent concurrency. Threads release the GIL during I/O (network, disk), so I/O-bound code benefits from threading.

Why It Exists

Simplifies CPython's C API and makes reference counting safe. Removing it would slow down single-threaded code.

How to Work Around It

Use multiprocessing for CPU-bound parallelism. Use asyncio or threading for I/O-bound concurrency.

import threading
import time

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:  # Need this for thread safety!
            counter += 1

# Even with GIL, compound operations like += aren't atomic
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()

print(f"Counter: {counter}")  # 200000 (correct with lock)
Output
Counter: 200000

GIL-Free Python?

Python 3.13 (2024) introduces an experimental "free-threaded" mode that removes the GIL. This is a huge development but still experimental. Other implementations like PyPy, Jython, and GraalPy don't have a GIL.

Memory Management

CPython uses two mechanisms for memory management: reference counting (primary) and garbage collection (for cycles).

Reference Counting

Every object has a reference count — the number of variables/containers pointing to it. When the count drops to zero, the memory is freed immediately:

import sys

a = [1, 2, 3]
print(f"After creation:  {sys.getrefcount(a)}")  # 2 (a + getrefcount arg)

b = a  # Another reference to the same list
print(f"After b = a:     {sys.getrefcount(a)}")  # 3

c = [a, a]  # Two more references
print(f"After c = [a,a]: {sys.getrefcount(a)}")  # 5

del b  # Remove one reference
print(f"After del b:     {sys.getrefcount(a)}")  # 4

# When refcount hits 0, memory is freed immediately
# (no need to wait for garbage collector)
Output
After creation:  2
After b = a:     3
After c = [a,a]: 5
After del b:     4

Garbage Collection for Cycles

Reference counting can't handle circular references (A points to B, B points to A). Python's garbage collector periodically scans for and breaks these cycles:

import gc

# Create a circular reference
a = []
b = [a]
a.append(b)  # a -> b -> a (cycle!)

# Reference counting alone can't free these
del a, b  # Refcounts don't reach zero due to the cycle

# The garbage collector handles it
collected = gc.collect()
print(f"Collected {collected} objects")
Output
Collected 2 objects

is vs == — Identity vs Equality

a = [1, 2, 3]
b = [1, 2, 3]
c = a

# == checks VALUE equality
print(f"a == b: {a == b}")  # True (same content)

# is checks IDENTITY (same object in memory)
print(f"a is b: {a is b}")  # False (different objects)
print(f"a is c: {a is c}")  # True (same object)

# Small integer caching (-5 to 256)
x = 256
y = 256
print(f"\n256 is 256: {x is y}")  # True (cached)

x = 257
y = 257
print(f"257 is 257: {x is y}")    # False (not cached!)

# Always use == for comparison, is only for None
print(f"\nNone is None: {None is None}")  # True (singleton)
Output
a == b: True
a is b: False
a is c: True

256 is 256: True
257 is 257: False

None is None: True
Key Takeaway: Use == to compare values. Use is only for None checks (x is None). Never use is to compare integers, strings, or other values — caching behavior is an implementation detail that can change.

Bytecode — What Python Actually Runs

When you run a Python file, it's first compiled to bytecode — a low-level instruction set for Python's virtual machine. You can inspect it with the dis module:

import dis

def add(a, b):
    return a + b

dis.dis(add)
Output
  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 RETURN_VALUE

Each .py file is compiled to bytecode and cached in __pycache__/*.pyc files. This is why the first import is slower (compilation) but subsequent imports are fast (cached bytecode).

How Imports Work

import sys

# Python searches these directories for modules:
for i, path in enumerate(sys.path[:5]):
    print(f"  {i}: {path}")

# A module is only loaded ONCE — subsequent imports return the cached version
print(f"\nLoaded modules: {len(sys.modules)}")
print(f"'os' loaded? {'os' in sys.modules}")

# You can see where a module was loaded from
import json
print(f"\njson location: {json.__file__}")
🔍 Deep Dive: CPython vs Other Implementations

CPython is the reference implementation, written in C. But there are others: PyPy uses a JIT compiler and can be 10-100x faster for some workloads. Jython runs on the JVM. GraalPy runs on GraalVM. MicroPython runs on microcontrollers. Cinder (Meta) adds lazy imports and other optimizations. When we talk about "Python internals," we usually mean CPython specifically.

⚠️ Common Mistake: Relying on Implementation Details

Wrong:

if x is 42:  # Relies on integer caching!
    print("Works in CPython, breaks in PyPy")

Why: Integer caching, string interning, and other optimizations are CPython implementation details. They can change between versions and don't exist in other Python implementations.

Instead:

if x == 42:  # Always correct
    print("Works everywhere")