A deep, interconnected walk through every mechanism that makes Python what it actually is — from bytecode to the GIL, from objects to async, told without shortcuts.
In C, an integer is a primitive. In Java, int is not an object (only Integer is). In Python, there is no such distinction. The number 42 is an instance of int. The function you wrote is an instance of function. Even the class int itself is an instance of type. And type is an instance of itself.
This is not a philosophical claim. It is mechanically true. And it's the single most important fact in the entire language because everything else follows from it.
# What does "everything is an object" mean concretely?
def greet(name):
return f"Hello, {name}"
# greet is an object — it has attributes
print(greet.__name__) # 'greet'
print(greet.__code__.co_varnames) # ('name',)
print(type(greet)) # <class 'function'>
# You can store it, pass it, return it — it's just a value
funcs = [len, str, greet]
for f in funcs:
print(f.__name__)
# Even the integer class is an object
print(type(int)) # <class 'type'>
print(type(type)) # <class 'type'> — type is its own type
Every object in Python has three things baked in: an identity (its address in memory, returned by id()), a type (what class made it), and a value (its data). This trinity is the atom of the whole system.
Because functions are objects, you can pass them as arguments. Because classes are objects, you can create them at runtime. Because everything has a type, the type system is uniform. Everything in Python — decorators, metaclasses, closures, generators — is just a consequence of this single architectural decision.
This is the most misunderstood thing about Python. When you write x = 5, Python does not create a box called x and put 5 in it. It creates an integer object with value 5 somewhere in memory, then makes the name x in the current namespace point to that object. The name is a label, not a container.
# Assignment is binding, not copying
a = [1, 2, 3]
b = a # b now points to the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] ← a sees the change
# a and b are two names for one object
print(id(a) == id(b)) # True
# Rebinding vs mutation
b = [10, 20] # b now points to a NEW object
print(a) # [1, 2, 3, 4] ← a unchanged
Integers, strings, tuples — these are immutable. Their internal state cannot change after creation. Lists, dicts, sets — mutable. This is not about whether you can rebind the name. It's about whether you can change the object the name points to.
# Immutable: you can rebind, but not mutate
s = "hello"
# s[0] = 'H' ← TypeError — strings don't support item assignment
s = s.upper() # This creates a NEW string, rebinds s to it
# The infamous mutable default argument trap
def add_item(item, lst=[]): # ← DANGER: default is created ONCE at def time
lst.append(item)
return lst
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] ← not [2]! the same list is reused
# The fix: use None as sentinel
def add_item_safe(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
CPython pre-allocates integer objects for values -5 through 256. This means a = 100; b = 100; a is b returns True — same object. But a = 1000; b = 1000; a is b may return False. This is why you must use == for value comparison and is only for identity checks (like is None).
Python is often called "interpreted." That's a simplification. What actually happens is this: your .py source is compiled to bytecode — a compact, intermediate instruction set for the CPython Virtual Machine. This bytecode is stored in .pyc files under __pycache__. Then a bytecode interpreter (the CPython VM) executes it, instruction by instruction.
The unit of bytecode is the code object — every function, class, and module has one. You can inspect it yourself:
import dis
def add(a, b):
return a + b
dis.dis(add)
# LOAD_FAST 0 (a) ← push a onto value stack
# LOAD_FAST 1 (b) ← push b onto value stack
# BINARY_OP 0 (+) ← pop both, add, push result
# RETURN_VALUE ← pop result, return it
# The code object contains everything the compiler captured
print(add.__code__.co_consts) # constants
print(add.__code__.co_varnames) # local variable names
print(add.__code__.co_filename) # source file
The CPython VM is a stack machine. It maintains a value stack. Instructions push values onto it and pop values off it. LOAD_FAST pushes a local variable. BINARY_OP pops two values, combines them, pushes the result. RETURN_VALUE pops the top of the stack and returns it. That's it. Everything — function calls, attribute access, even exception handling — compiles down to sequences of these stack operations.
This is also why Python is slower than C for compute-heavy loops: each bytecode dispatch requires bounds checking, type checking, and dynamic attribute lookup, while C compiles directly to native machine instructions.
Understanding the eval loop explains why x += 1 is different from x = x + 1 for lists (+= calls __iadd__ in-place), why list comprehensions are faster than equivalent for-loops (they generate tighter bytecode), and why local variable access is faster than global (LOAD_FAST vs LOAD_GLOBAL — different opcode, different cost).
Python's type system is fully dynamic and unified. A type is just a class. A class is just an object. When you write class Dog:, Python calls type.__new__(type, 'Dog', bases, namespace) and binds the result to the name Dog. You can do the same thing yourself:
# Creating a class manually — this is what `class` keyword does internally
Dog = type('Dog', (object,), {
'species': 'Canis familiaris',
'bark': lambda self: print("Woof!")
})
rex = Dog()
rex.bark() # Woof!
print(type(rex)) # <class '__main__.Dog'>
print(type(Dog)) # <class 'type'>
type(x) == SomeClass checks exact type. isinstance(x, SomeClass) checks the entire inheritance chain. In production code, you almost always want isinstance because it respects subclassing. This connects to the Liskov Substitution Principle — a subclass should work wherever the parent is expected.
class Animal: pass
class Dog(Animal): pass
rex = Dog()
print(type(rex) == Animal) # False — rex is a Dog, not an Animal
print(isinstance(rex, Animal)) # True — Dog IS-A Animal
# Type annotations are just metadata, not enforcement
def feed(animal: Animal) -> str: # annotation is informational only
return f"Feeding {animal}"
feed(42) # Works at runtime. Type checkers (mypy) catch this, Python doesn't.
Python operators and built-in functions work by calling specific double-underscore (dunder) methods on objects. When you write a + b, Python actually calls a.__add__(b). When you write len(x), it calls x.__len__(). When you iterate a for loop, Python calls __iter__, then repeatedly calls __next__. This is called the Data Model, and it's how Python achieves its expressive power without special-casing anything.
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other): # v1 + v2
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar): # v * 3
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar): # 3 * v (right operand)
return self.__mul__(scalar)
def __abs__(self): # abs(v)
return (self.x**2 + self.y**2) ** 0.5
def __repr__(self): # repr(v) — for debugging
return f"Vector({self.x}, {self.y})"
def __bool__(self): # bool(v) — truthiness
return bool(abs(self))
v1 = Vector(2, 3)
v2 = Vector(1, 1)
print(v1 + v2) # Vector(3, 4)
print(3 * v1) # Vector(6, 9)
print(abs(v1)) # 3.605...
The key insight: the language doesn't know what a Vector is. It only knows about protocols — the set of dunder methods that objects can implement to participate in language operations. for loops work on anything with __iter__. with statements work on anything with __enter__ and __exit__. [] indexing works on anything with __getitem__.
__eq__, __lt__, __le__, __gt__, __ge__
Define __eq__ and Python will use it for ==. If you define __eq__ without __hash__, the object becomes unhashable (can't be a dict key).
__len__, __getitem__, __setitem__, __contains__
Implement these and your class works with len(), [], in operator, and slicing — indistinguishable from a built-in list.
When you call a method, Python needs to find it. In single inheritance this is trivial — walk up the chain. With multiple inheritance, the order matters. Python uses the C3 linearization algorithm (sometimes called C3 MRO), which guarantees that: (1) subclasses appear before parents, (2) the order from the class definition is respected, and (3) monotonicity — if A comes before B in the MRO of C, it comes before B in every subclass of C.
class A:
def method(self): print("A")
class B(A):
def method(self): print("B")
class C(A):
def method(self): print("C")
class D(B, C): # Diamond inheritance
pass
d = D()
d.method() # B — follows MRO
print(D.__mro__) # (D, B, C, A, object)
# super() respects MRO — it doesn't mean "parent class"
# It means "next in MRO" — crucial for cooperative multiple inheritance
class B2(A):
def method(self):
super().method() # calls C.method if D is the starting class, not A.method
print("B2")
Thinking super() means "call the parent." It means "call the next class in the MRO of the instance being constructed." In a diamond hierarchy, super() in B calls C, not A. If you hardcode A.method(self), you bypass MRO and break cooperative inheritance.
The practical takeaway: in deep class hierarchies, every class in a cooperative chain should call super(). That way, the MRO ensures each class in the chain gets called exactly once, in the right order, even in diamond cases.
When you access obj.attr, Python doesn't just look it up in a dictionary. It runs an attribute lookup protocol. Simplified: first it checks the class (and its parents via MRO) for a data descriptor — an object that defines both __get__ and __set__. If found, that descriptor handles the access. Then it checks the instance's __dict__. Then it looks for a non-data descriptor in the class (defines only __get__).
# @property is just a built-in descriptor
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be non-negative")
self._radius = value
# Now write a descriptor manually to understand what @property does under the hood
class ValidatedFloat:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None: return self # accessed from class, not instance
return obj.__dict__.get(self.name, 0.0)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name} must be a number")
obj.__dict__[self.name] = float(value)
class Product:
price = ValidatedFloat() # descriptor instance lives on the CLASS
weight = ValidatedFloat()
p = Product()
p.price = 9.99 # calls ValidatedFloat.__set__
p.price = "cheap" # TypeError
This mechanism is how @property, @staticmethod, @classmethod, and __slots__ all work. Functions are non-data descriptors — that's how calling obj.method() automatically passes obj as self: function.__get__(obj, type) returns a bound method.
Python resolves names using the LEGB rule: Local → Enclosing → Global → Built-in. When Python compiles a function and finds a name that isn't local, it looks in the enclosing function's scope. If found there, it becomes a free variable — the inner function captures a reference to the cell that holds it, not a copy of the value. This capture is a closure.
def make_counter(start=0):
count = [start] # using a list to allow mutation (see note)
def increment(by=1):
count[0] += by # mutates the list — closes over `count`
return count[0]
return increment
counter = make_counter(10)
print(counter()) # 11
print(counter(5)) # 16
# In Python 3 you can use `nonlocal` to rebind enclosing variables
def make_counter_clean(start=0):
count = start
def increment(by=1):
nonlocal count # declares intent to rebind, not just read
count += by
return count
return increment
Late-binding closures in loops. funcs = [lambda: i for i in range(3)] — all three lambdas return 2, not 0, 1, 2. They all close over the same i variable cell, and by the time you call them, i is 2. Fix: lambda i=i: i — capture the value as a default argument, which is evaluated immediately at lambda creation time.
When a variable is shared between an inner and outer function, CPython wraps it in a cell object. Both the outer and inner function's code objects reference this cell. The cell holds a pointer to the actual value. This is why closures capture the variable itself, not just its value at the time of capture.
def outer():
x = 10
def inner():
return x
return inner
f = outer()
print(f.__closure__) # (<cell at 0x...>,)
print(f.__closure__[0].cell_contents) # 10
A decorator is syntactic sugar. @timer above a function f is exactly equivalent to f = timer(f). That's it. A decorator takes a callable, wraps it in another callable (usually using closures), and returns the wrapper. Because functions are first-class objects, this is trivially expressible in Python.
import time
import functools
def timer(func):
@functools.wraps(func) # preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_thing():
time.sleep(0.1)
# Parameterized decorators: add another layer of wrapping
def retry(times=3, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == times - 1:
raise
return None
return wrapper
return decorator
@retry(times=3, exceptions=(IOError,))
def read_file(path): ...
Class-based decorators work too: implement __call__ and you have a callable object that wraps the function. They're useful when the decorator needs to maintain state across calls — like a cache or a call counter.
The iterator protocol is simple: an object is iterable if it has __iter__ that returns an iterator. An iterator is an object with __next__ that returns the next value or raises StopIteration. A generator is Python's built-in mechanism for writing iterators without the boilerplate.
# Generator function — creates a generator object
def fibonacci():
a, b = 0, 1
while True:
yield a # suspends here, saves all local state
a, b = b, a + b # resumes here on next()
fib = fibonacci() # creates a generator object — no computation yet
print([next(fib) for _ in range(8)]) # [0, 1, 1, 2, 3, 5, 8, 13]
# Generator expressions — like list comps but lazy
squares = (x**2 for x in range(1_000_000)) # 0 elements computed yet
print(next(squares)) # 0 — computes only when asked
# yield from — delegate to sub-iterator
def chain(*iterables):
for it in iterables:
yield from it # equivalent to: for x in it: yield x, but optimized
list(chain([1,2], [3,4], [5])) # [1, 2, 3, 4, 5]
When a generator function is called, Python creates a generator object which contains a copy of the function's frame (local variables, execution pointer, value stack). Calling next() resumes the frame until the next yield. The frame is then suspended — the execution pointer remembers exactly where it paused. This is why generators are memory-efficient: a fibonacci() generator uses constant memory regardless of how many values you generate.
itertools.chain, islice, groupby, product — all generators. Build pipelines with them and you get lazy evaluation for free. sum(x*x for x in huge_file) reads the file line by line, never holding it all in memory.
Async is often taught as "it's faster." That's wrong. Async is about concurrency for I/O-bound tasks — specifically, it lets you do something useful while waiting for a slow operation to complete. A regular function blocks the thread when it waits. An async function suspends itself and lets the event loop run other things. It's cooperative: each coroutine yields control voluntarily.
import asyncio
# A coroutine — calling it returns a coroutine object, doesn't run it
async def fetch(url):
print(f"Starting: {url}")
await asyncio.sleep(1) # suspends coroutine, returns control to event loop
print(f"Done: {url}")
return f"data from {url}"
async def main():
# Sequential — total time ~2 seconds
await fetch("url1")
await fetch("url2")
# Concurrent — total time ~1 second (both run simultaneously)
results = await asyncio.gather(
fetch("url1"),
fetch("url2"),
)
asyncio.run(main())
Under the hood, coroutines are built on generators. async def creates a coroutine object. await is essentially yield from applied to an awaitable. The event loop maintains a queue of ready tasks. When a task awaits an I/O operation, it registers a callback with the OS (via select/epoll) and suspends. The event loop runs other tasks. When the OS signals that I/O is ready, the callback wakes the task, and the event loop schedules it to resume.
| Model | Good for | Mechanism | Python tool |
|---|---|---|---|
| Async | Many concurrent I/O tasks | Single thread, cooperative yield | asyncio, aiohttp |
| Threading | I/O-bound with blocking libs | OS threads, preemptive | threading, concurrent.futures |
| Multiprocessing | CPU-bound computation | Separate processes, bypasses GIL | multiprocessing |
Calling a blocking function (like time.sleep or a synchronous database driver) inside an async function blocks the entire event loop — no other coroutine can run while it blocks. Use asyncio.sleep for delays, asyncio.to_thread() to run blocking calls in a thread pool from async context.
The GIL is a mutex — a lock — that CPython holds whenever it executes Python bytecode. Only one thread can execute Python bytecode at a time, even on multi-core machines. This sounds catastrophically limiting. But the reasons for it are deep, and the consequences are more nuanced than "Python can't use multiple cores."
CPython's memory management (reference counting) is not thread-safe. Every object has a reference count. When a thread increments or decrements this count, another thread can't be allowed to simultaneously do the same, or the count corrupts. The GIL is a single coarse lock that sidesteps the need for fine-grained locks on every object — making the implementation simpler and single-threaded code faster.
import sys
import threading
x = []
# sys.getrefcount(x) == 2 (one for x, one for getrefcount's argument)
# Every time a new name points to x, refcount increments
# When a name goes out of scope, refcount decrements
# When refcount reaches 0, CPython immediately deallocates the object
# This is safe only because the GIL ensures atomic refcount operations
# Without GIL: thread A reads count=1, thread B reads count=1,
# both decrement to 0, object freed twice → segfault
# GIL is released during I/O — so threads are genuinely concurrent for I/O
# GIL is NOT released during pure Python computation — threads take turns
For I/O-bound tasks: threads work fine. The GIL is released while waiting on network/disk, so threads genuinely overlap. For CPU-bound tasks: use multiprocessing, not threading. Each process has its own GIL (and its own Python interpreter), so they run truly in parallel. NumPy/SciPy release the GIL during their C-level computations, which is why they can be parallelized.
Experimental "free-threaded" CPython (no-GIL mode) is available as an opt-in build in Python 3.13. It replaces per-object reference counting with biased reference counting and adds fine-grained locking. The ecosystem is still adapting, but the GIL era is ending.
CPython uses a two-layer memory strategy. The primary mechanism is reference counting: every object tracks how many names point to it. When that count drops to zero, the object is immediately deallocated — no waiting for a GC cycle. This gives Python deterministic cleanup (which is why with statements are guaranteed to run __exit__).
The secondary mechanism is the cyclic garbage collector, which handles the one case reference counting can't: reference cycles. If object A points to B and B points to A, neither's refcount ever reaches zero, even if nothing else references them. The cyclic GC periodically scans for these isolated cycles and collects them.
import gc
import weakref
# Reference cycle — both stay alive even after going out of scope
class Node:
def __init__(self): self.next = None
a = Node()
b = Node()
a.next = b
b.next = a # cycle!
del a, b # refcounts: 1 each (from each other), not 0
# cyclic GC will eventually collect these
# weakref — reference that doesn't increment refcount
# Useful in caches so cached objects can still be GC'd
obj = Node()
ref = weakref.ref(obj)
print(ref()) # <Node object>
del obj
print(ref()) # None — object was collected
CPython doesn't call malloc for every small object. It maintains its own pymalloc — a memory pool for allocations ≤ 512 bytes, organized into arenas (256KB), pools (4KB), and blocks. This dramatically reduces allocation overhead and fragmentation for the many small, short-lived objects Python code creates constantly. Large objects (> 512 bytes) go directly to the system allocator.
By default, every instance stores its attributes in a __dict__ — a full hash table. For classes with millions of instances (like graph nodes), this is expensive. Declaring __slots__ = ('x', 'y') tells Python to store attributes in a compact fixed-size array instead. Typical savings: 3–5× less memory per instance. Trade-off: you can't add arbitrary attributes.
A metaclass is to a class what a class is to an instance. Just as Dog is a factory that makes dog instances, a metaclass is a factory that makes class objects. The default metaclass is type. You can replace it with your own to intercept class creation and customise what classes look like or how they behave.
# When Python sees `class Dog(Animal):`, it:
# 1. Collects the class body into a dict namespace
# 2. Calls Animal's metaclass (type, by default) with (name, bases, namespace)
# 3. Binds the result to `Dog`
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self): self.connection = "connected"
db1 = Database()
db2 = Database()
print(db1 is db2) # True — same object
# ABCMeta is a metaclass that powers abstract base classes
from abc import ABCMeta, abstractmethod
class Shape(metaclass=ABCMeta):
@abstractmethod
def area(self) -> float: ...
Metaclasses are powerful but rarely necessary. Use __init_subclass__ (Python 3.6+) or class decorators for most "hook into class creation" use cases — they're cleaner. Metaclasses shine when you're building a framework that needs to transform class definitions (like Django's ORM, which uses them to turn class attributes into database columns).
The with statement is more than "automatically close files." It's a protocol for guaranteed setup and teardown regardless of whether an exception occurs. The protocol: __enter__ runs on entry (its return value is bound by as). __exit__ always runs on exit, whether normally or via exception. If __exit__ returns truthy, the exception is suppressed.
# The contextlib way — simpler than writing __enter__/__exit__
from contextlib import contextmanager
@contextmanager
def timer(label):
import time
start = time.perf_counter()
try:
yield # everything in the `with` block runs here
finally:
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.4f}s")
with timer("matrix multiply"):
result = expensive_computation() # timer runs even if this raises
# The full __enter__/__exit__ protocol
class ManagedFile:
def __init__(self, path, mode):
self.path, self.mode = path, mode
def __enter__(self):
self.file = open(self.path, self.mode)
return self.file # this becomes the `as` target
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
return False # don't suppress exceptions
Context managers underpin connection pools, database transactions, locks (threading.Lock() is a context manager), and temporary state changes. contextlib.suppress(Exception) is a context manager that silences specified exceptions. contextlib.ExitStack lets you stack an arbitrary number of context managers dynamically.
By now you can see how every section in this post links to every other. The data model — Python's system of special methods and protocols — is the thread that connects them all. It's not a list of tricks. It's an architecture.
"Python doesn't add magic on top of objects. It builds the language out of objects. The abstraction and the mechanism are the same thing." — a thought worth sitting with
# 1. __new__ vs __init__
# __new__ creates the object. __init__ initialises it.
# __new__ is a static method that returns the instance.
# Useful for immutable types (you can't set values in __init__ on an immutable)
class Celsius(float):
def __new__(cls, value):
if value < -273.15:
raise ValueError("Below absolute zero")
return super().__new__(cls, value)
# 2. __slots__ vs __dict__
# Covered in memory section — but also note:
# __slots__ prevents the creation of __dict__ entirely,
# saving ~200 bytes per instance for large datasets
# 3. Abstract base classes — structural subtyping
from abc import ABC, abstractmethod
class Serializable(ABC):
@abstractmethod
def to_json(self) -> str: ...
@abstractmethod
def from_json(cls, data: str): ...
# 4. dataclasses — @dataclass is a class decorator that auto-generates
# __init__, __repr__, __eq__, optionally __hash__ and __slots__
from dataclasses import dataclass, field
@dataclass(frozen=True) # frozen=True → immutable + __hash__
class Point:
x: float
y: float
tags: list = field(default_factory=list) # safe mutable default
A module is an object. Its namespace is its __dict__. When you write import foo, Python checks sys.modules first (the module cache). If found, returns the cached object. Otherwise, finds the file, compiles it to bytecode, executes the bytecode in a fresh namespace, stores the result in sys.modules, and binds it to the name. A from foo import bar is just: do all that, then bind bar = foo.bar locally. Circular imports fail because by the time A tries to import B which imports A, sys.modules['A'] exists but isn't fully populated yet.
The programmer who understands Python's machinery isn't impressed by Python. They're at home in it.