Everything Is an Object - The Core of Python's Runtime Model
Reading time: ~25 minutes | Level: Foundation → Engineering
Consider this code:
def double(x):
return x * 2
operations = [double, abs, len]
print(operations[0](5)) # 10
print(operations[1](-3)) # 3
print(operations[2]("hi")) # 2
If you come from Java or C++, storing functions in a list feels strange - almost illegal. In those languages, functions are not values. They live in a separate conceptual space. You cannot pass them around the way you pass integers or strings.
In Python, this code is entirely ordinary. And the reason it works is the single most important architectural decision in the language: everything is an object. Not metaphorically. Not as a design pattern you apply sometimes. Literally - every value you ever touch in Python is an object with an identity, a type, and a value, sitting on the heap, managed by the runtime.
Understanding this one idea unlocks decorators, metaclasses, higher-order functions, frameworks like FastAPI and Django, and the entire dynamic character of the language. This lesson goes deep.
What You Will Learn
- What the claim "everything is an object" actually means at the C level
- How CPython represents every Python value as a
PyObjectC struct - What
id(),type(), andisinstance()reveal - and where they mislead - Why functions are first-class objects and what that enables
- How classes themselves are objects created by the
typemetaclass - The attribute lookup chain: instance dict → class dict → base classes
- The crucial difference between identity (
is) and equality (==) - Performance consequences: the small int cache, string interning, singleton
None - Pitfalls that have burned real engineers in production systems
Prerequisites
- Basic Python variables and functions (previous lessons)
- Familiarity with the concept of a variable name vs. a value
- Conceptual knowledge of what a C struct is (helpful but not required)
The Central Claim: Every Value Is a PyObject
Here is a question that reveals whether you truly understand Python's model. What does this print?
x = 42
print(type(x))
print(x.__class__)
print(x.bit_length())
print(x.__add__(8))
<class 'int'>
<class 'int'>
6
50
The integer 42 has a class. It has methods. You can call .bit_length() on it. That is not possible if integers are raw CPU primitives. It is only possible because 42 is a full Python object - a heap-allocated structure with metadata attached.
In CPython (the reference implementation of Python, written in C), every Python object is backed by a C struct. The minimal version, PyObject, looks like this:
PyObject (simplified)
+------------------+
| ob_refcnt | <-- reference count for garbage collection
+------------------+
| *ob_type | <-- pointer to the type object (e.g., int, str, list)
+------------------+
| ... type-specific data ...
+------------------+
For an integer, CPython extends this with PyLongObject, which adds the actual numeric data. For a string, it is PyUnicodeObject with character buffer storage. For every Python value - including functions, classes, modules, and None - there is a corresponding C struct anchored on those two fields: ob_refcnt and ob_type.
This is why you can call type() on anything. The ob_type pointer is always there.
print(type(42)) # <class 'int'>
print(type("hello")) # <class 'str'>
print(type([1, 2, 3])) # <class 'list'>
print(type(None)) # <class 'NoneType'>
print(type(print)) # <class 'builtin_function_or_method'>
Every single one of those values is an object. The print function. The None singleton. A literal integer. All of them.
The Identity, Type, and Value Trinity
Python's data model defines three properties every object possesses:
Identity: id()
id(obj) returns an integer that uniquely identifies the object for the duration of its lifetime. In CPython, this is literally the memory address of the PyObject struct.
x = [1, 2, 3]
y = [1, 2, 3]
z = x
print(id(x)) # e.g., 140234567890
print(id(y)) # different - different object
print(id(z)) # same as id(x) - same object
id() values can be reused. After an object is garbage collected, a new object can be allocated at the same address and get the same id. Never cache an id value across a point where the original object might be collected and replaced.
Type: type()
type(obj) returns the type object - the class whose code defines the behavior of this object.
x = 3.14
print(type(x)) # <class 'float'>
print(type(x) is float) # True
Every type is itself an object. And every type's type is type, which is Python's built-in metaclass:
print(type(int)) # <class 'type'>
print(type(str)) # <class 'type'>
print(type(type)) # <class 'type'> -- type is its own type
This circular arrangement is not accidental. It is what makes the entire object system consistent and extensible. There is no privileged primitive that stands outside the object model.
isinstance() vs type(): Which One to Use
This is a question that appears in nearly every Python interview, and for good reason. The answer has real engineering implications.
class Animal:
pass
class Dog(Animal):
pass
dog = Dog()
print(type(dog) is Dog) # True
print(type(dog) is Animal) # False <-- inheritance ignored
print(isinstance(dog, Dog)) # True
print(isinstance(dog, Animal)) # True <-- inheritance respected
type(obj) is SomeClass performs an exact match. It checks whether the object's type pointer points to exactly that class. It ignores inheritance completely.
isinstance(obj, SomeClass) walks the Method Resolution Order (MRO) - the inheritance chain - and returns True if the object's type is SomeClass or any subclass of it.
In production code, almost always prefer isinstance(). It respects inheritance and makes your code work correctly when someone subclasses one of your types. Using type(x) is SomeType is appropriate only when you need to reject subclasses explicitly - a rare situation.
isinstance() also accepts a tuple of types, which is very useful:
def process_number(x):
if not isinstance(x, (int, float)):
raise TypeError(f"Expected a number, got {type(x).__name__}")
return x * 2
First-Class Functions: Functions Are Objects
The term "first-class" in programming language theory means: a value that can be stored in a variable, passed as an argument, returned from a function, and placed in a data structure. In Python, functions are first-class because they are objects.
def greet(name):
return f"Hello, {name}"
# Store in a variable
f = greet
print(f("Alice")) # Hello, Alice
# Store in a list
funcs = [greet, len, str.upper]
# Pass as an argument
def apply(func, value):
return func(value)
print(apply(greet, "Bob")) # Hello, Bob
# Return from a function
def make_multiplier(n):
def multiplier(x):
return x * n
return multiplier # returning a function object
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
This is not magic. It works because greet is just a name that refers to an object of type function. You can verify this:
print(type(greet)) # <class 'function'>
print(greet.__name__) # greet
print(greet.__code__) # <code object greet at 0x...>
print(greet.__defaults__) # None
Functions carry metadata as attributes. They are objects with state. This is the foundation for decorators, callbacks, event handlers, middleware, and every higher-order programming pattern you will use in Python.
Classes Are Objects: The Metaclass Chain
When you write a class definition, Python executes it and creates an object. That object is of type type.
class Dog:
species = "Canis lupus familiaris"
def bark(self):
return "Woof!"
print(type(Dog)) # <class 'type'>
print(isinstance(Dog, type)) # True
print(Dog.species) # Canis lupus familiaris
Dog is a name that refers to an object of type type. You can pass it around, store it in a list, return it from a function - all the same behaviors as any other object:
def make_class(name):
return type(name, (object,), {"x": 42})
MyDynamicClass = make_class("MyDynamicClass")
obj = MyDynamicClass()
print(obj.x) # 42
This is how frameworks like Django dynamically generate model classes, and how ORMs introspect and build schema from your class definitions.
The full metaclass chain looks like this:
Everything Has Attributes: dir() and __dict__
Because every value is an object, every value has attributes accessible with dot notation.
x = 42
print(x.__class__) # <class 'int'>
print(x.__doc__[:40]) # int([x]) -> integer...
print(dir(x)) # ['__abs__', '__add__', ..., 'bit_length', ...]
For user-defined objects, attributes are stored in an instance dictionary:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p.__dict__) # {'x': 3, 'y': 4}
Classes also have a __dict__ that stores their methods and class-level attributes:
print(Point.__dict__)
# mappingproxy({'__init__': <function Point.__init__ at 0x...>,
# '__dict__': <attribute '__dict__' of 'Point' objects>,
# '__weakref__': ..., '__doc__': None})
The Attribute Lookup Chain
When you write obj.x, Python does not simply look in one place. It follows a precise lookup protocol:
ATTRIBUTE LOOKUP: obj.x
=======================
Step 1: Check type(obj).__mro__ for data descriptors
(e.g., properties with __set__)
Step 2: Check obj.__dict__
(the instance's own namespace)
Step 3: Check type(obj).__dict__ and each class in the MRO
(the class namespace and all base classes)
Step 4: Check type(obj).__mro__ for non-data descriptors
(e.g., plain functions)
Step 5: Raise AttributeError
In practice, for ordinary attribute access:
class Animal:
kingdom = "Animalia"
def breathe(self):
return "inhale/exhale"
class Dog(Animal):
def bark(self):
return "woof"
d = Dog()
d.name = "Rex"
# Lookup order for d.name:
# 1. d.__dict__ -> found: "Rex"
# Lookup order for d.bark:
# 1. d.__dict__ -> not found
# 2. Dog.__dict__ -> found: bark function
# Lookup order for d.kingdom:
# 1. d.__dict__ -> not found
# 2. Dog.__dict__ -> not found
# 3. Animal.__dict__ -> found: "Animalia"
This chain is the mechanism behind inheritance, method resolution, and descriptors (which underlie @property, @staticmethod, @classmethod, and slot-based optimizations).
Object Identity vs Equality: is vs ==
This distinction trips up engineers at every experience level.
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b) # True -- same value, calls a.__eq__(b)
print(a is b) # False -- different objects in memory
print(a == c) # True
print(a is c) # True -- same object
is checks whether two names refer to the exact same object - the same memory address. == calls __eq__ to check whether two objects have the same value.
Never use is to compare integers, strings, or other values for equality in general code. The following code works in CPython due to implementation details but is incorrect and unreliable:
# WRONG -- relies on CPython's small int cache
x = 1000
y = 1000
print(x is y) # False in most contexts -- unpredictable
Use is only for:
- Comparing to
None:if x is None - Comparing to
TrueorFalsewhen you specifically need identity (rare) - Checking whether two references point to the same mutable object
The correct idiom for None checking is always is:
def process(value=None):
if value is None: # correct
value = []
return value
Using == for None works in practice (because NoneType.__eq__ returns NotImplemented for non-None values, which falls back to identity), but is None communicates intent more clearly and is the universally accepted style.
Performance: Small Int Cache, String Interning, and Singletons
CPython makes three notable optimizations that follow from the object model:
Small Integer Cache
CPython pre-creates integer objects for values from -5 to 256 at startup. Every time you use the integer 42 in your program, you get the same cached object.
a = 42
b = 42
print(a is b) # True -- same cached object
x = 1000
y = 1000
print(x is y) # False -- new objects created each time
# (result varies by context)
This optimization saves memory and allocation overhead for the most common small integers.
String Interning
CPython interns (caches) strings that look like valid Python identifiers. Two variable names that refer to the same identifier string may point to the same object:
a = "hello"
b = "hello"
print(a is b) # True (interned -- implementation-specific)
a = "hello world" # contains a space -- less likely to be interned
b = "hello world"
print(a is b) # False in some contexts
Never rely on string interning for program correctness. It is an implementation detail of CPython that can change. Always use == to compare string values.
Singleton None, True, False
None, True, and False are true singletons - there is exactly one instance of each in the entire Python process:
print(None is None) # always True
print(True is True) # always True
print(False is False) # always True
This is why is None is both safe and idiomatic - you are guaranteed there is only one None object.
Pitfalls
Pitfall 1: Mutating a Shared Mutable Default
# BROKEN
def append_to(value, lst=[]):
lst.append(value)
return lst
print(append_to(1)) # [1]
print(append_to(2)) # [2] ? NO -- [1, 2]
print(append_to(3)) # [3] ? NO -- [1, 2, 3]
The default argument [] is evaluated once when the function object is created, and it is stored as an attribute of the function object. Every call that uses the default shares the same list object. This is a direct consequence of the fact that functions are objects with state.
The fix:
def append_to(value, lst=None):
if lst is None:
lst = []
lst.append(value)
return lst
Pitfall 2: Confusing is with ==
# WRONG
if type(x) is str: # breaks for subclasses of str
...
# BETTER
if isinstance(x, str):
...
Pitfall 3: Assuming id() Values Are Stable
# DO NOT DO THIS
ids = [id(x) for x in range(1000, 2000)]
# These objects are immediately eligible for garbage collection.
# A later id() call may return the same value for a different object.
Interview Questions
Q1: What does "everything is an object" mean at the implementation level in CPython?
In CPython, every Python value - integers, strings, functions, classes, modules, None - is represented as a heap-allocated C struct that begins with a PyObject header. That header contains ob_refcnt (the reference count used by the garbage collector) and ob_type (a pointer to the type object that defines the value's behavior). This means you can call type() on any value, pass any value as a function argument, store any value in a data structure, and introspect any value's attributes. There is no conceptual split between "primitive" and "object" - the model is fully uniform.
Q2: What is the difference between id(), type(), and value for a Python object?
These are the three properties every Python object possesses per the data model. id() returns a unique identifier for the object's lifetime - in CPython this is the memory address of the underlying C struct. type() returns the type object (the class) that defines the behavior of the object - it is the ob_type pointer from the C struct. The "value" is the type-specific data stored in the struct beyond the PyObject header - for an int it is the numeric magnitude, for a list it is the array of pointers to contained objects. The identity can be reused after garbage collection; the type determines what operations are valid; the value is what the programmer thinks of as the data.
Q3: Why are functions first-class objects in Python, and what does this enable?
Functions are first-class because they are instances of the function type - they are heap-allocated objects with attributes like __name__, __code__, __defaults__, and __closure__. Because they are objects, they can be stored in variables, placed in lists, passed as arguments, and returned from other functions. This enables decorators (functions that accept and return functions), higher-order programming patterns like map and filter, callback systems, middleware pipelines, and the entire functional programming subset of Python. Every Python framework relies on passing functions as objects - Django views, FastAPI route handlers, click command functions.
Q4: What is a metaclass, and why is type(int) equal to type?
A metaclass is the class of a class - it controls how classes are created. In Python, type is the default metaclass. When you write class Foo: pass, Python calls type("Foo", (object,), {}) internally to create the class object. Since int, str, list, and every user-defined class are all objects created by type, calling type() on any of them returns type. type is also an instance of itself because the metaclass chain has to bottom out somewhere and Python resolves this with a deliberate circularity. The practical consequence is that you can use type() to dynamically create classes at runtime, which is what ORMs and serialization frameworks do.
Q5: When should you use isinstance() versus type() for type checking?
Use isinstance() in virtually all production code because it is inheritance-aware. isinstance(obj, Base) returns True if obj is an instance of Base or any subclass of Base. This means your code works correctly when callers pass subclass instances, which is the normal expectation in object-oriented systems. type(obj) is SomeClass performs an exact identity check against the type object - it returns False for subclass instances. The only legitimate use of exact type() comparison is when you deliberately need to reject subclasses - for example, in a factory function that needs to dispatch on the precise type. Even then, consider whether an explicit protocol or isinstance check against an abstract base class is cleaner.
Q6: Why do integers in Python have methods, and what does that tell us about the object model?
Integers having methods - (42).bit_length(), (42).__add__(8), (42).__class__ - demonstrates that integers are genuine objects, not raw CPU primitives. In CPython, every integer is a PyLongObject struct with the standard PyObject header pointing to the int type object. The int type object defines all the methods available on integers. When you call (42).bit_length(), Python performs normal attribute lookup: it checks the integer's own __dict__ (empty for small ints), then checks int.__dict__, and finds the bit_length method there. The Python integer is slower than a C int by design - it trades raw speed for the uniformity and power of the object model.
Graded Practice Challenges
Level 1 - Predict the Output
What does this code print? Think carefully before running it.
a = 256
b = 256
c = 257
d = 257
print(a is b)
print(c is d)
print(a == b)
print(c == d)
Show Answer
True
False
True
True
256 falls within CPython's small integer cache (-5 to 256), so a and b refer to the same cached object - is returns True. 257 is outside the cache, so c and d are separate objects created at runtime - is returns False. However, == compares values using __eq__ for both cases, so both comparisons return True. This demonstrates why you must use == for value comparison and reserve is for identity checks (like is None).
Level 2 - Debug This
A teammate wrote the following code to accumulate results. It fails silently. Identify the bug, explain why it happens using your knowledge of the object model, and fix it.
def collect(item, bucket=[]):
bucket.append(item)
return bucket
r1 = collect("apple")
r2 = collect("banana")
r3 = collect("cherry")
print(r1)
print(r2)
print(r3)
Show Answer
The bug: The default argument bucket=[] creates a single list object when the function object is created (at def time), and stores it as collect.__defaults__[0]. Every call that omits bucket receives a reference to that same list object. Mutations to it persist across calls.
Output you actually get:
['apple', 'banana', 'cherry']
['apple', 'banana', 'cherry']
['apple', 'banana', 'cherry']
All three names point to the same list because append mutated the shared default. The first collect call returned the default list after adding "apple". The second call added "banana" to the same list. All three names end up referencing the same object.
The fix:
def collect(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
r1 = collect("apple")
r2 = collect("banana")
r3 = collect("cherry")
print(r1) # ['apple']
print(r2) # ['banana']
print(r3) # ['cherry']
Now each call creates a fresh list object when bucket is None. The None singleton is safe as a default because it is immutable and identity-checkable with is None.
Level 3 - Design Challenge
Design a simple function registry that:
- Stores functions by name in a dictionary
- Allows calling a registered function by its string name
- Lists all registered function names
- Works as a decorator
Your registry should demonstrate that functions are objects by storing their metadata alongside them. Include at least __name__ and __doc__ in what you store.
# Expected usage:
@registry.register
def add(x, y):
"""Return the sum of x and y."""
return x + y
@registry.register
def multiply(x, y):
"""Return the product of x and y."""
return x * y
registry.call("add", 3, 4) # 7
registry.call("multiply", 3, 4) # 12
registry.list_all()
# [('add', 'Return the sum of x and y.'),
# ('multiply', 'Return the product of x and y.')]
Show Answer
class FunctionRegistry:
def __init__(self):
# Functions are objects -- store them in a dict like any other value
self._store = {}
def register(self, func):
"""Decorator: register a function object by its __name__."""
self._store[func.__name__] = func # func is an object
return func # return unchanged so the name still works normally
def call(self, name, *args, **kwargs):
if name not in self._store:
raise KeyError(f"No function registered under '{name}'")
func = self._store[name] # retrieve the function object
return func(*args, **kwargs) # call the function object
def list_all(self):
return [
(name, (func.__doc__ or "").strip())
for name, func in self._store.items()
]
registry = FunctionRegistry()
@registry.register
def add(x, y):
"""Return the sum of x and y."""
return x + y
@registry.register
def multiply(x, y):
"""Return the product of x and y."""
return x * y
print(registry.call("add", 3, 4)) # 7
print(registry.call("multiply", 3, 4)) # 12
print(registry.list_all())
# [('add', 'Return the sum of x and y.'),
# ('multiply', 'Return the product of x and y.')]
Key insight: register uses func.__name__ and func.__doc__ - attributes that only exist because functions are full objects with an attribute namespace. The @registry.register syntax desugars to add = registry.register(add), which stores the function object in the dict and returns it unchanged. The entire pattern depends on functions being first-class objects.
Quick Reference Cheatsheet
| Operation | What It Does | Example |
|---|---|---|
id(obj) | Memory address (CPython) - unique during lifetime | id(42) → 140234567 |
type(obj) | Returns the type object (class) | type(42) → <class 'int'> |
isinstance(obj, T) | True if obj is T or subclass of T | isinstance(True, int) → True |
isinstance(obj, (T1, T2)) | True if obj is any of the given types | isinstance(3.0, (int, float)) → True |
obj.__dict__ | Instance attribute namespace | p.__dict__ → {'x': 3, 'y': 4} |
obj.__class__ | Same as type(obj) | (42).__class__ → <class 'int'> |
dir(obj) | All attribute names visible via lookup | dir([]) → ['append', 'clear', ...] |
a is b | Identity: same object in memory | None is None → True |
a == b | Equality: same value via __eq__ | [1] == [1] → True |
type(type) | Metaclass chain root | → <class 'type'> |
type("C", (B,), {}) | Dynamically create a class | Creates class C inheriting B |
func.__name__ | Function's name as a string | len.__name__ → 'len' |
func.__doc__ | Function's docstring | abs.__doc__ → 'Return...' |
Key Takeaways
- Every Python value - integers, strings, functions, classes, modules,
None- is a heap-allocated object with an identity, a type, and a value. There are no primitives that stand outside this model. - In CPython, every object begins with a
PyObjectC struct containingob_refcnt(reference count) andob_type(pointer to the type). All Python behavior derives from this uniform structure. id()returns the memory address in CPython and is only meaningful during an object's lifetime. Do not cacheidvalues across GC cycles.type()returns the type object.type(type)istype. All classes are instances oftype.- Prefer
isinstance()overtype()comparison for type checking in production code -isinstancerespects inheritance; exact type comparison does not. - Functions are objects of type
functionwith attributes like__name__,__code__, and__defaults__. This is why they are first-class: they can be stored, passed, returned, and stored as attributes. - Classes are objects of type
type. You can create classes dynamically, store them in variables, and return them from functions. - Attribute lookup follows the chain: instance
__dict__→ class__dict__→ MRO base class dicts. This chain is the mechanism behind inheritance and descriptors. ischecks identity (same object).==checks value (calls__eq__). Useisonly forNone,True,Falsechecks and explicit identity comparisons of mutable objects.- CPython caches integers -5 through 256 and interns simple strings. These are implementation details - never rely on them for program correctness. Always use
==for value comparison. - The mutable default argument trap is a direct consequence of functions being objects: the default list is created once when the function object is created and persists as
func.__defaults__[0].
