The Object Model
Every Python object is a PyObject
Section titled “Every Python object is a PyObject”Every value you have ever touched in Python — an int, a list, a function, a module, a class itself — is a heap-allocated C struct that starts with the same two fields: a reference count and a pointer to its type. CPython’s C source calls this header PyObject. Everything else about the object (its actual data — the digits of an int, the elements of a list) comes after that shared header.
flowchart TD PO["PyObject header"] --> RC["ob_refcnt (reference count)"] PO --> TP["ob_type (pointer to the type object)"] PO --> DATA["...type-specific data (int digits, list items, __dict__, ...)"] TP -.-> TYPEOBJ["type object (itself a PyObject, type == type(type))"]
This one fact explains three things you already use constantly: id(x) is (conceptually) the address of this struct; type(x) reads ob_type; and sys.getrefcount(x) reads ob_refcnt.
id and is
Section titled “id and is”id(x) returns a unique integer identifying an object for its lifetime — in CPython this is the object’s memory address. x is y compares id(x) == id(y): it asks “are these the same object,” not “do they compare equal.”
a = [1, 2, 3]b = a # b names the SAME object as ac = [1, 2, 3] # c is a DIFFERENT object with equal contents
print(a is b, a == b) # True True -- same object, and equalprint(a is c, a == c) # False True -- different objects, equal contentssys.getrefcount — and why it’s +1 (or +2)
Section titled “sys.getrefcount — and why it’s +1 (or +2)”sys.getrefcount(x) reads the object’s ob_refcnt directly. Passing x to getrefcount itself creates one more temporary reference — the argument binding inside the call — so the number you see is always at least one higher than the “real” reference count you were trying to measure:
import sys
x = object()print(sys.getrefcount(x)) # 2: the `x` name, + the argument to getrefcount itselfy = xprint(sys.getrefcount(x)) # 3: `x`, `y`, + the argument to getrefcountdel yprint(sys.getrefcount(x)) # back to 2Real output, python3.14:
232That “+1 for the call” rule holds for an ordinary object. It does not hold for None, True, False, or small cached integers — those are immortal as of Python 3.12 (PEP 683): their refcount field is set to a fixed sentinel value and CPython’s Py_INCREF/Py_DECREF skip touching it entirely, because they can never be collected anyway.
import sys
print(sys.getrefcount(None)) # a huge, fixed number -- not "how many places reference None"print(sys.getrefcount(42)) # 42 is in the small-int cache -- also immortalprint(hex(sys.getrefcount(42)))Real output, python3.14:
322122547232212254720xc00000000xc0000000 is the immortal-object sentinel bit pattern (PEP 683’s _Py_IMMORTAL_REFCNT), not “how many references exist.” A plain object() instance, or an int outside the small-int cache, gives an ordinary, meaningful count instead — verified above and again below.
The small-int cache
Section titled “The small-int cache”CPython pre-creates and caches every int from -5 to 256 at startup. is-comparing two of these always gives True, because every reference to, say, 100, points at the same cached object. Outside that range, ordinary int literals with the same compile-time constant may also share an object (constant folding), but ints constructed at runtime from separate operations are not guaranteed to, and usually don’t:
for n in (-6, -5, 0, 256, 257): p = n q = int(str(n)) # force a genuinely separate int object built at runtime print(f"n={n:>4} p is q: {p is q}")Real output, python3.14:
n= -6 p is q: Falsen= -5 p is q: Truen= 0 p is q: Truen= 256 p is q: Truen= 257 p is q: False-5 and 256 are the exact boundaries: -6 and 257 are one step outside the cache and is immediately returns False for a freshly-built copy. Never rely on is to compare integers — it happens to work by accident inside the cached range and silently breaks the moment a value crosses 256, which is exactly the kind of bug that survives a quick manual test and fails in production.
String interning
Section titled “String interning”CPython automatically interns string literals that look like identifiers (letters, digits, underscores) at compile time, so two occurrences of the same identifier-like literal are usually the same object. Strings built at runtime (.join, slicing, concatenation) are not automatically interned — sys.intern forces it explicitly:
import sys
s1 = "hello"s2 = "hello"print("identifier-like literal:", s1 is s2)
s3 = "".join(["hel", "lo"])print("runtime-built 'hello' is s1:", s3 is s1)
s4 = sys.intern("".join(["hel", "lo", " ", "world"]))s5 = sys.intern("hello world")print("sys.intern unifies both:", s4 is s5)Real output, python3.14:
identifier-like literal: Trueruntime-built 'hello' is s1: Falsesys.intern unifies both: TrueThe same warning applies as for small ints: == is always correct for comparing string contents; is only happens to work for interned literals, and a dict lookup or a network response builds a string object the interning rules never touch.
__dict__ vs __slots__
Section titled “__dict__ vs __slots__”By default, every instance carries its own __dict__ — a real dict, allocated separately from the instance itself, mapping attribute names to values. __slots__ tells CPython to skip that dict entirely and store the named attributes in fixed C-level slots on the instance struct instead:
import sys
class Plain: def __init__(self, x, y): self.x = x self.y = y
class Slotted: __slots__ = ("x", "y")
def __init__(self, x, y): self.x = x self.y = y
p = Plain(1, 2)sl = Slotted(1, 2)print("Plain instance:", sys.getsizeof(p), "+ its __dict__:", sys.getsizeof(p.__dict__))print("Slotted instance:", sys.getsizeof(sl), "(no separate __dict__ exists at all)")
try: sl.z = 3except AttributeError as e: print("Slotted instance rejects a new attribute:", e)Real output, python3.14:
Plain instance: 48 + its __dict__: 296Slotted instance: 48 (no separate __dict__ exists at all)Slotted instance rejects a new attribute: 'Slotted' object has no attribute 'z' and no __dict__ for setting new attributesThe instance struct itself is the same 48 bytes either way here, but Plain also drags along a 296-byte dict per instance — real memory that __slots__ eliminates entirely, at the cost of never being able to add an attribute (z) that wasn’t declared up front. For a class with millions of instances (rows of parsed data, nodes in a tree), this difference is exactly where __slots__ earns its place; for a handful of long-lived objects it rarely matters.
Attribute lookup order
Section titled “Attribute lookup order”obj.attr does not simply check the instance __dict__. CPython’s actual order is: data descriptors on the type (checked first, via the MRO) → the instance __dict__ → non-data descriptors and plain class attributes on the type (via the MRO). A data descriptor is any object defining both __get__ and __set__ (or __delete__); it wins even over a value already sitting in the instance’s own __dict__:
class LoudDescriptor: def __get__(self, obj, objtype=None): return "from descriptor"
def __set__(self, obj, value): raise AttributeError("read-only data descriptor")
class WithDescriptor: attr = LoudDescriptor()
w = WithDescriptor()w.__dict__["attr"] = "from instance dict" # written directly, bypassing __set__print("instance __dict__ actually holds:", w.__dict__["attr"])print("but w.attr resolves to:", w.attr)Real output, python3.14:
instance __dict__ actually holds: from instance dictbut w.attr resolves to: from descriptorThis is exactly the mechanism behind property (properties are data descriptors) — self.x = 5 on a class with x defined as a @property never touches self.__dict__["x"] in a way that could shadow the property, because the descriptor is checked first, unconditionally.
type is a metaclass
Section titled “type is a metaclass”type is not just “the thing type(x) returns” — it is itself a class, and it is the class every ordinary class is an instance of. Calling type(name, bases, namespace) with three arguments creates a new class at runtime, which is exactly what the class statement compiles down to:
print("type(type):", type(type))print("type(int):", type(int))
class Ordinary: pass
print("type(Ordinary):", type(Ordinary))
Dynamic = type("Dynamic", (), {"greet": lambda self: "hi"})d = Dynamic()print("dynamically created class works:", d.greet())Real output, python3.14:
type(type): <class 'type'>type(int): <class 'type'>type(Ordinary): <class 'type'>dynamically created class works: hitype(type) is type itself — the recursion bottoms out there by design. This is also the actual mechanism behind every metaclass you will encounter in a framework (ORMs, ABCs, enum): a metaclass is simply a subclass of type, used as the third argument’s producer instead of plain type.
__class__ can be reassigned
Section titled “__class__ can be reassigned”Because ob_type is just a pointer, and (for compatible layouts) nothing stops you from repointing it after the fact — assigning to obj.__class__ swaps an existing instance’s type in place, changing which methods it resolves to without creating a new object:
class A: def who(self): return "A"
class B: def who(self): return "B"
obj = A()print("before:", obj.who())obj.__class__ = Bprint("after: ", obj.who())Real output, python3.14:
before: Aafter: Bobj is the same object (id(obj) never changes) throughout — only what ob_type points at changes. This only works between classes with compatible instance layouts (same __slots__/__dict__ shape); CPython raises TypeError if the layouts are incompatible.
Full runnable demo
Section titled “Full runnable demo”import sys
# --- id / is ---print("=== id and is ===")a = [1, 2, 3]b = ac = [1, 2, 3]print(f"a is b: {a is b}, a == b: {a == b}")print(f"a is c: {a is c}, a == c: {a == c}")
# --- getrefcount +1 ---print("=== sys.getrefcount ===")x = object()print(f"just x: {sys.getrefcount(x)}")y = xprint(f"x and y: {sys.getrefcount(x)}")del yprint(f"back to just x: {sys.getrefcount(x)}")
# --- small int cache ---print("=== small int cache boundaries ===")for n in (-6, -5, 0, 256, 257): p = n q = int(str(n)) print(f" n={n:>4} p is q: {p is q}")
# --- string interning ---print("=== string interning ===")s1 = "hello"s2 = "hello"print(f" identifier-like literal: {s1 is s2}")s3 = "".join(["hel", "lo"])print(f" runtime-built same text: {s3 is s1}")s4 = sys.intern("".join(["hel", "lo", " ", "world"]))s5 = sys.intern("hello world")print(f" sys.intern unifies: {s4 is s5}")
# --- __dict__ vs __slots__ ---print("=== __dict__ vs __slots__ ===")
class Plain: def __init__(self, px, py): self.px = px self.py = py
class Slotted: __slots__ = ("px", "py")
def __init__(self, px, py): self.px = px self.py = py
p = Plain(1, 2)sl = Slotted(1, 2)print(f" Plain instance: {sys.getsizeof(p)} + dict: {sys.getsizeof(p.__dict__)}")print(f" Slotted instance: {sys.getsizeof(sl)} (no dict at all)")try: sl.extra = 3except AttributeError as e: print(f" Slotted rejects new attr: {e}")
# --- attribute lookup order: data descriptor beats instance dict ---print("=== attribute lookup order ===")
class LoudDescriptor: def __get__(self, obj, objtype=None): return "from descriptor"
def __set__(self, obj, value): raise AttributeError("read-only")
class WithDescriptor: attr = LoudDescriptor()
w = WithDescriptor()w.__dict__["attr"] = "from instance dict"print(f" instance dict holds: {w.__dict__['attr']}")print(f" w.attr resolves to: {w.attr}")
# --- type is a metaclass ---print("=== type as a metaclass ===")print(f" type(type): {type(type)}")Dynamic = type("Dynamic", (), {"greet": lambda self: "hi"})print(f" dynamic class instance: {Dynamic().greet()}")
# --- __class__ swap ---print("=== __class__ swap ===")
class A: def who(self): return "A"
class B: def who(self): return "B"
obj = A()before_id = id(obj)print(f" before: {obj.who()}")obj.__class__ = Bprint(f" after: {obj.who()}, same object: {id(obj) == before_id}")Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”Every name in your program is a label pointing at a PyObject on the heap — never a box that holds a copy of a value. x = y copies the pointer, not the object; x is y asks whether two labels point at the same struct; id(x) is (conceptually) that struct’s address. Attribute access on an instance is not “look in a dict” — it is a fixed search order (data descriptor on the type, then the instance dict, then everything else on the type) that exists specifically so that property, __slots__, and ordinary attributes can all coexist predictably. And type sits at the bottom of that search as both “the thing every class is an instance of” and “a class you can call directly” — there is no separate “class-creation subsystem”; class Foo: ... and type("Foo", (), {...}) are the same operation.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
is for identity checks | O(1) pointer comparison, exactly right for x is None/singleton checks | Easy to misuse for value comparison — works by accident for small ints/interned strings, breaks silently outside those ranges |
__slots__ | Real memory savings (no per-instance dict) at scale; typo’d attribute names raise immediately | No dynamic attributes, no default multiple inheritance with other __slots__ classes without care, weakref needs an explicit slot |
Data descriptors (property and friends) | Attribute access can run code, validate, or compute lazily, transparently | One more step in an already non-obvious lookup order; overusing them makes a class harder to reason about than plain attributes |
type(name, bases, ns) dynamic class creation | Frameworks (ORMs, dataclasses-like tools) can generate classes programmatically | Debugging a dynamically-created class is harder — no source line to point at |
Common Misconceptions
Section titled “Common Misconceptions”- “
isis just a faster==.” It answers a different question entirely (same object vs. equal value) and only coincides with==reliably for identity-appropriate comparisons likex is None. Using it on ints or strings works by luck inside CPython’s caching ranges and fails outside them. - “
sys.getrefcount(x)tells you exactly how many references exist.” For an ordinary object it is off by (at least) one because the call itself adds a reference; forNone, booleans, and small cached ints it returns a fixed immortal-object sentinel (0xc0000000since Python 3.12’s PEP 683) that has nothing to do with reference count at all. - “
__slots__makes attribute access faster.” Its real, verified benefit here is memory (48 bytes vs. 48 + 296 bytes for two attributes) — CPython’s own benchmarks show a modest access-speed difference, but memory density on large object counts is the reason to reach for it. - “An instance’s own
__dict__is always checked first.” A data descriptor defined on the class (including everyproperty) is checked before the instance__dict__, unconditionally — verified above by writing directly intow.__dict__and seeing the descriptor still win. - “Metaclasses are an advanced, separate feature from normal classes.”
typeis both the metaclass of every ordinary class and something you can call directly with three arguments to build a class at runtime — theclassstatement is sugar over exactly that call.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does the code use
is(oris not) to compare twoints,strs, or other value types instead of==? It may pass every test the author tried and still break the moment a value leaves the small-int cache or an interned range. - Does a class define
__eq__but rely onisanywhere nearby for the “same object” case, without commenting on which is intended? The two are easy to swap by accident. - Does a class use
__slots__and also define a mutable class-level default, or forget__weakref__in the slots tuple when something elsewhere holds aweakrefto instances? Both are common__slots__mistakes. - Does generated code assume
sys.getrefcount(x) == 1means “safe to mutate” or “no other owner,” without accounting for the call’s own +1, or for immortal objects?
🐛 Spot the bug
def get_status_code(value): if value is 200: return "OK" if value is 404: return "Not Found" return "Unknown"
print(get_status_code(int(input())))Show the answer
200 falls inside CPython’s small-int cache (-5 to 256), so value is 200 happens to work when value is the literal 200 from source code or another small-int-cached reference — but int(input()) builds a brand-new int object from parsed text, and for values that size CPython does not guarantee it reuses the cached object (and for 404, which is outside the cache entirely, it never does). Typing 404 at the prompt returns "Unknown" here, not "Not Found", because is compares identity and the freshly-parsed 404 is not the same object as any cached 404. The fix is value == 200 / value == 404 — == compares the actual integer value regardless of which object holds it, which is what this function actually needs.