CPython Object Model and Reference Counting — Trying to make sense of PyObject and PyTypeObject
Before starting, I think I should say that this article is quite long, and take some breaks within sections to understand everything properly and also there are interactive code examples which you should try, especially if you are new to these topics, let me also try to answer the inevitable question:
Why should anyone care about this?
For me, it’s about intellectual satisfaction and academic interest. I simply want to understand my tools better. Python is my go-to language for everything; it’s the language of my life, so knowing it more deeply makes me happy.
For others, or perhaps even for you, there might be different reasons. But if you’ve just stumbled upon this and are wondering why you should care, let me share some insights that might pique your interest:
- Write More Performant Python Code: By knowing how objects are allocated and deallocated and how references impact memory, you can write code that is more memory-efficient and avoids unnecessary overhead.
- Debug Memory-Related Issues More Effectively: Understanding reference counting is crucial for identifying and resolving memory leaks, excessive memory consumption and unexpected object lifetimes.
- Optimize C Extensions for Python: If you’re writing C extensions to improve performance or interact with low-level libraries, a deep understanding of PyObject and reference counting is absolutely essential for correct memory management and avoiding crashes.
- Contribute to CPython Development: For those aspiring to contribute to the Python core or develop sophisticated tools that interact deeply with the interpreter, this knowledge is a prerequisite.
- Understand Advanced Python Concepts: Many advanced Python features, such as metaclasses, descriptors, and the gcmodule, become much clearer when you grasp the underlying object model.
- Make Informed Architectural Decisions: When designing large-scale Python applications, knowing the implications of object creation, immutability, and memory management helps you make better choices for data structures and overall system design.
- Enhance Cybersecurity Understanding: This knowledge is crucial for identifying, analyzing, and fixing security vulnerabilities in Python applications and the CPython interpreter itself, especially those related to memory management and object handling. It’s also vital for understanding exploits and malware.
These insights can also be particularly handy for Computer Science students or researchers when learning about programming language implementation as part of their studies.
(Throughout this article, “Python” exclusively denotes the CPython implementation, which is the main-adopted version of the language). All code examples provided are accessible via a single Google Colab instance at https://colab.research.google.com/drive/1RfLUdQbQWDMxonA5Zidn4teNKxfLYQ6d?usp=sharing, requiring no local setup to run and observe the output. This Colab link will also be periodically reminded in each relevant section. Full code examples within the article will link directly to their corresponding GitHub Gists for easy reference.
Also Note that, code snippets that I will use by referencing actual source files are simplified for understanding the core behaviors and structure, the actual implementation might be very different at first glance due to having extra code for optimizations and all.
Now that we have a reason, let’s get started!
1. The Base: PyObject/PyVarObject and Memory Management
A. The Heart of Everything: PyObject/PyVarObject and PyObject_HEAD/PyObject_VAR_HEAD
“Everything is an object in python”, this is not merely a high-level abstraction; it translates directly into the fundamental architecture of its reference implementation, CPython. Every Python object is represented as a C structure, specifically PyObjectbeginning with a standardized header. At minimum, this header includes a reference count and a pointer to the object’s type. In C these are encapsulated by the macros PyObject_HEAD and PyObject_VAR_HEAD. For example, in CPython’s source (in Include/object.h), the core idea is that a fixed-size object’s header is conceptually structured as:
typedef struct _object {
PyObject_HEAD /* macro expanding to ob_refcnt and ob_type */
} PyObject;
And a variable-sized object:
typedef struct {
PyObject_VAR_HEAD /* includes ob_refcnt, ob_type, and ob_size */
} PyVarObject;
Specifically, PyObject_HEAD expands to
Py_ssize_t ob_refcnt;
struct _typeobject *ob_type; // Often aliased as PyTypeObject*
This means that, at its most fundamental level, the PyObject structure directly contains:
typedef struct {
Py_ssize_t ob_refcnt; /* object reference count */
PyTypeObject* ob_type; /* object type */
} PyObject;
And similarly, PyObject_VAR_HEAD expands to PyObjectHEAD followed by Py_ssize_t ob_size;, making PyVarObject conceptually:
typedef struct {
Py_ssize_t ob_refcnt; /* object reference count */
PyTypeObject* ob_type; /* object type */
Py_ssize_t ob_size; /* number of items in variable part (e.g., list length) */
} PyVarObject;
(Note: While these typedef examples illustrate the fundamental structure after macro expansion, the actual CPython source (e.g., in Include/object.h) achieves this through a more layered approach where PyVarObject embeds a PyObject as its base, and macros like PyObject_HEAD are primarily used when defining other object types to ensure they start with these common fields. But this is fine for our simple understanding purposes.)
This means every Python object has an ob_refcnt (its reference count) and ob_type (pointer to its PyTypeObject). Variable-sized objects (like list or tuples) uses PyObject_VAR_HEAD, which adds a Py_ssize_t ob_size field for its length. Thus, the memory layout of an object always begins with these fields, and the order of this layout is fixed; first ob_refcnt, then the ob_type pointer, followed by ob_size(if variable-sized), and then any instance-specific fields. CPython internally aligns these fields according to the platform’s Application Binary Interface (ABI).
It is important to note that while all Python objects conceptually begin with this structure, nothing is explicitly declared as a PyObject. Instead, any pointer to a Python object can be safely cast to a PyObject *, giving a consistent interface to the most fundamental object properties.
For stability across Python versions and to ensure binary compatibility for C extensions, direct member access to ob_refcnt, ob_type or ob_size is strongly discouraged. CPython provides specific macros like Py_REFCNT(obj), Py_TYPE(obj), and Py_SIZE(obj)for this purpose. The use of these macros is a critical mechanism to maintain ABI stability, ensuring that even if the internal layout of these core structures changes in future CPython versions, existing C extensions do not require recompilation (pretty sick heh). This fosters a stable and evolving ecosystem.
B. Core Memory Management: Reference Counting
CPython’s primary memory-management strategy is reference counting.
The ob_refcnt field (Py_ssize_t), in which the reference counting heavily relies on, is a counter that tracks the number of " strong" references pointing to the object.
The CPython API distinguishes between “ strong ” (or “ owned ”) and “ borrowed ” references. A “ strong ” reference implies that the holder is responsible for decrementing the object’s reference count when the reference is no longer required. Functions that return “ new references ” provide strong references. In contrast, a “ borrowed ” reference signifies that the holder does not own the reference and, critically, should not decrement its count. These are typically temporary views into an object.
When a new reference to an object is created, its ob_refcnt must be incremented (Py_INCREF); when a reference is released, it must be decremented (Py_DECREF). The CPython runtime exposes macros and functions for this:
- Py_INCREF(op): Increment op->ob_refcnt.
- Py_DECREF(op): Decrement op->ob_refcnt; if the count becomes zero, it triggers the object's deallocation process.
- Py_XINCREF(op) / Py_XDECREF(op): Like the above, but first checks if op is not NULL, avoiding null-pointer dereferences.
In the source, these are defined as below (simplified for clarity, as the actual code includes debug accounting macros) :
#define Py_INCREF(op) (((PyObject*)(op))->ob_refcnt++)
#define Py_DECREF(op) do { \
if (--((PyObject*)(op))->ob_refcnt != 0) \
; /* still live */ \
else \
_Py_Dealloc((PyObject*)(op)); \
} while (0)
Thus, when Py_DECREF drops the reference count to zero, it invokes _Py_Dealloc, which in turn calls the object’s tp_dealloc function. As the C API documentation notes, "Once the last strong reference is released… the object’s type’s deallocation function (which must not be NULL) is invoked."
Because forgetting to update reference counts causes leaks (if too few decrements) or crashes (if too many and the object gets freed prematurely), the rule is: every code path that creates or holds a reference must ensure a matching INCREF / DECREF . For example, when assigning an object to a new holder, one might do Py_INCREF(obj); holder->attr = obj;. Python’s C-API docs advise always wrapping pointer assignments with these macros. In code, a safer pattern when setting fields in a tp_dealloc or tp_clear function is to use Py_CLEAR , which does a safe null-checked DECREF:
#define Py_CLEAR(op) do { \
if ((op) != NULL) { \
PyObject *_tmp = (PyObject*)(op); \
(op) = NULL; \
Py_DECREF(_tmp); \
} \
} while(0)
This is recommended to avoid subtle bugs, such as if the deallocation triggered new code that re-accessed the pointer. It is essential to use Py_INCREF and Py_DECREF (or their X variants) rather than manipulating ob_refcnt directly, as these macros handle crucial nuances like debug hooks and immortal objects, ensuring correctness. The "X" variants (Py_XINCREF / Py_XDECREF) simply do nothing if passed a NULL, avoiding the need for explicit null checks before calling them. Python’s C API docs summarize their role: "Py_INCREF indicates taking a new strong reference… Py_DECREF releases a strong reference. When done using the object, release it by calling Py_DECREF."
The embedding of ob_refcnt directly within the PyObject header is a deliberate architectural choice. This physical proximity ensures that reference count operations are highly efficient, enabling the prompt release of memory as soon as an object is no longer referenced. This design prioritizes immediate resource cleanup for the vast majority of objects. For C extension developers, correctly managing this count is paramount to prevent insidious memory leaks or catastrophic use-after-free errors.
2. The Link to Behavior: “ob_type“ and the “PyTypeObject” Blueprint
The behavior and layout of an object’s data come from its type object. Every type in CPython is represented by a PyTypeObject structure. This structure begins with PyObject_VAR_HEAD so type objects themselves are full python objects, and yes type is an instance of itself (I know, it’s pretty Ziggazagga, yeah I don’t think that’s a word.)
So, as discussed in 1-A, PyObject_HEAD has this ob_type field, which is the link or the pointer to that object’s type information. Yes, now you understand why I used that word up there.
A. The Link to Type Information
The ob_type field (PyTypeObject*) is a pointer to the object’s type object. This pointer is the basically how the CPython achieves polymorphism and dynamic typing. Every object “knows” its type, and through that type, it understands its intrinsic behavior like its methods, its attributes, and supported operations.
For instance, an Integer object’s ob_type member points to &PyLong_Type, a string object's to &PyUnicode_Type, instances of user-defined classes will point to their corresponding PyTypeObject. This ob_type pointer forms the very foundation of Python's dynamic typing and object-oriented features at the C level. By centralizing behavioral definitions in PyTypeObject, the interpreter can dispatch calls and resolve attributes at runtime without requiring explicit type checks for every operation, which underpins Python's "duck typing" philosophy; “If it walks like a duck and it quacks like a duck, then it must be a duck.” thing, anyway that’s something to talk later, I’m quickly getting distracted writing this.
B. The Comprehensive Behavioral Blueprint (An Introduction to PyTypeObject)
We talked about that Ziggazagga thing earlier, signifying that type objects themselves are full Python objects. And how type (the metaclass for all types) is an instance of itself, conceptually embodying this recursive nature within the interpreter. Now let’s take a look at the definition of PyTypeObject (in Include/object.h) :
typedef struct _typeobject {
PyObject_VAR_HEAD /* ob_refcnt, ob_type (metatype), ob_size */
const char *tp_name; /* type name, e.g. "int", "module.Class" */
Py_ssize_t tp_basicsize; /* instance size in bytes */
Py_ssize_t tp_itemsize; /* for var-sized objects (e.g. tuple) */
/* Methods to implement standard operations (examples) */
destructor tp_dealloc; /* how to free an instance */
/* ... many more fields for attribute access, etc. ... */
initproc tp_init; /* __init__ : initialize new instances */
allocfunc tp_alloc; /* memory allocator for instances */
newfunc tp_new; /* __new__ : create and return new instances */
freefunc tp_free; /* low-level deallocation of memory */
inquiry tp_is_gc; /* support for cyclic GC */
/* ... */
} PyTypeObject;
Yeah, there is a lot. These fields serve as pointers to C functions or contain vital metadata, effectively encoding all the behavior of a type. Like tp_name stores the human readable name of the type (ex: “int”, “str”, “MyBadClass”). Fields like tp_basicsize and tp_itemsize determines the memory allocation needs for instances of this type. tp_new and tp_init are correspond to Python’s __new__ and __init__ methods, which does the object creation and initialization. More specifically, the tp_new points to a function that is responsible for allocating and returning a fresh instance (usually by calling PyObject_New or PyObject_NewVar)
The tp_dealloc function is what cleans up the object, when an object’s reference count drops to zero (it’s what you call a “destructor function”, so essentially tp_dealloc is a pointer to this function). Let’s look at a custom extension type implementation:
static void
foo_dealloc(foo_object *self) {
/* release contained references, possibly via Py_CLEAR() */
Py_XDECREF(self->inner_ref);
/* call tp_free to free memory */
Py_TYPE(self)->tp_free((PyObject*)self);
}
The tp_free function is typically PyObject_Del (if object was allocated with PyObject_New ) or PyObject_GC_Del (for GC tracked objects) .
“The destructor function is called by the Py_DECREF() and Py_XDECREF()macros when the new reference count is zero. … The destructor should free all references the instance owns, free any memory buffers owned by the instance, and call the type’s tp_free function.” — excerpt from the doc: https://docs.python.org/3/c-api/typeobj.html — specifically from here
And fields like tp_is_gc determines if the type participates in cyclic garbage collection. If so, the runtime will also call the type’s tp_traverse and tp_clear functions when collecting cycles.
This honestly is just merely scratching the surface of PyTypeObject‘s capabilities. In the following sections, we will dive more into Structure, Semantics and Object Lifecycle Management like things and gets our hands dirty with some real coding.
3. Deep into PyTypeObject — Structure, Semantics and LifeCycle
A. The “tp slots”: A Comprehensive Look at Type Behavior
This object we are talking about, the PyTypeObject structure is a vast collection of C function pointers and other fields, which collectively known as “slots”. These slots are the C-Level implementation of Python’s special methods ( such as tp_name -> __name__ , tp_dealloc -> __del__ and tp_repr -> __repr__ )and other intrinsic behaviors. When defining a custom type in C (we are going to do that later in this article), developers populate these slots with pointers to C functions that implement the desired Python behavior. The PyType_Ready() function is then invoked to finalize the type object, populating any unassigned slots with default implementations and preparing the type for use within the interpreter.
These methods (such as __name__, __repr__, __add__ etc) are also known as “dunder” methods (short for “double underscore”)
1. Core Identity and Layout
Lets talk about the slots that define a type’s fundamental identity and its instances’ memory footprint.
tp_name is of course the most straightforward one, it’s simply a C string ( const char * ) that holds the type’s fully qualified name (ex: int or module.ClassName . This is basically how the interpreter knows what kind of thing it’s dealing with. It’s like the name tag of the type.
tp_doc which is also a const char * , and is pointing to the type’s docstring, when we do something like MyBadClass.__doc__ in Python, that is where it pulls that from.
Then there are two fields which take care of how much memory a type’s instance will occupy:
- tp_basicsize (Py_ssize_t): Defines the minimum number of bytes required (or the base memory required) to store a single instance of the type.
- tp_itemsize ( Py_ssize_t ): Specifies the per-item size for variable-length objects, such as tuples or strings, so like how many extra bytes to allocate for each item depending on how many elements the object will hold.
2. Attribute Access
The way Python handles attribute access is what brings flexibility to its highly dynamic object model.
The two main function pointers are:
- tp_getattro (getattrofunc): This slot is called whenever an attribute is accessed such as obj.attr. It effectively maps to Python’s __getattribute__, and supports the fallback behavior of __getattr__ when needed.
- tp_setattro (setattrofunc): This slot is responsible for attribute assignments and deletions (obj.attr = val or del obj.attr). It's the C-level equivalent of __setattr__ and __delattr__
In addition to above raw function hooks, we have arrays that allows types to present a structured view of their attributes and methods:
- tp_methods (array of PyMethodDef): This defines the regular methods available on the type, these are the classic C-extension methods that become bound functions when accessed from Python.
- tp_members (array of PyMemberDef): These describe basic C struct fields directly exposed as attributes. They are useful for simple int, float, or pointer fields that map directly from C to Python.
- tp_getset (array of PyGetSetDef): This defines custom getter/setter pairs that work like properties, invoking logic on access, similar to what you’d write using the @property decorator in pure Python.
These slots and arrays collectively establish the low-level framework behind Python’s attribute handling mechanism. They support everything from simple field access to complete descriptor protocol integration, all while adhering to the Method Resolution Order (MRO).
We’ll explore the details of MRO later in another section, since it determines the exact order in which Python searches for attributes across complex inheritance hierarchies.
3. Protocol Implementations
PyTypeObject also has various pointers to sub structures and individual function pointers. These are the key “Protocol implementations” that determine how an object responds to different Python operations. The brilliance of this approach allow a single object to behave as a number, a sequence, a mapping, or be callable, iterable or comparable.
- tp_as_number ( PyNumberMethods * ), this pointer is to PyNumberMethods structure, a collection of C functions that says what to do when treated like a number. If this pointer is not NULL, the object can perform defined arithmetic operations, such as those implemented by the __add__ and __sub__ etc methods. These methods allow for operations like addition and subtraction to be performed on instances of the object.
for example:
class CustomNumber:
def __init__ (self, value):
self.value = value
def __repr__ (self):
"""
Provides a developer-friendly string representation of the object.
"""
return f"CustomNumber({self.value})"
# --- Arithmetic Dunder Methods ---
def __add__ (self, other):
"""
Implements the addition operator (+).
Called for CustomNumber() + other.
"""
return CustomNumber(self.value + other.value)
def __sub__ (self, other):
"""
Implements the subtraction operator (-).
Called for CustomNumber() - other.
"""
return CustomNumber(self.value - other.value)
def __mul__ (self, other):
"""
Implements the multiplication operator (*).
Called for CustomNumber() * other.
"""
return CustomNumber(self.value * other.value)
def __truediv__ (self, other):
"""
Implements the true division operator (/).
Called for CustomNumber() / other.
"""
if other.value == 0:
raise ZeroDivisionError("division by zero")
return CustomNumber(self.value / other.value)
# --- Demonstration ---
print("--- CustomNumber Instances ---")
num1 = CustomNumber(10)
num2 = CustomNumber(5)
num3 = CustomNumber(2.5)
print(f"num1: {num1}")
print(f"num2: {num2}")
print(f"num3: {num3}")
print("\n--- Basic Arithmetic Operations ---")
print(f"{num1} + {num2} = {num1 + num2}")
print(f"{num1} - {num2} = {num1 - num2}")
print(f"{num1} * {num2} = {num1 * num2}")
print(f"{num1} / {num2} = {num1 / num2}")
# --- Output ---
"""
--- CustomNumber Instances ---
num1: CustomNumber(10)
num2: CustomNumber(5)
num3: CustomNumber(2.5)
--- Basic Arithmetic Operations ---
CustomNumber(10) + CustomNumber(5) = CustomNumber(15)
CustomNumber(10) - CustomNumber(5) = CustomNumber(5)
CustomNumber(10) * CustomNumber(5) = CustomNumber(50)
CustomNumber(10) / CustomNumber(5) = CustomNumber(2.0)
"""
- tp_as_sequence (PySequenceMethods * ) — Points to PySequenceMethod structure, this also has C functions like above that defines the behavior for object that act like sequences, meaning objects with ordered collections where elements can be accessed by an integer index. Python types such as lists , tuples and str are primarily rely on this protocol. For example, sq_length implements __len__ , which is used by the len() function, sq_item implements __getitem__ for integer indexing (ex: some_obj[0] ). This structure also defines many other operations for sequences, including those for mutable sequences ( __setitem__, __delitem__), and operations like concatenation and repetition.
- tp_as_mapping (PyMappingMethods *) , directs to PyMappingMethods structure, and defines how the object behaves as a mapping, mapping is a collection where elements are accessed by arbitrary keys (like strings, numbers, or other hashable objects — most commonly the Python dict). Similar to sequences this also implements __len__ via mp_length (for mappings) and __getitem__ via mp_subscript ( for key based lookup ex: some_obj['key'] ), similarly this structure also defines operations for mappings, including those for mutable mappings (ex: some_obj['key'] = value, del some_obj['key'] )
Now we have more of these. I will simply list them here along with what they implement. You can always find more details about them individually, so I see no point in going through all of them. Hopefully, the above ones will give you enough idea of “Protocol Implementations.”
- tp_hash (hashfunc): Implements the object's __hash__ method.
- tp_call (ternaryfunc): Implements the object's __call__ method.
- tp_iter (getiterfunc : Implements the object’s __iter__ method.
- tp_iternext (iternextfunc): Implements an iterator's __next__ method.
- tp_richcompare (richcmpfunc): Implements the "rich comparison" methods (__lt__, __le__, __eq__, __ne__, __gt__, __ge__)
B. Modifying Type Semantics (tp_flags)
The tp_flags field contains a bitmask of unsigned_long values that represent the meaning and behavior of the type. The bitmask offers a very lightweight and compact way to specify the semantics of a type and also lend itself to other optimizations. Since the semantics are in the form of a bitmask, the type can be very explicit about the behavior it is defining, this is a common C level pattern used to represent and manage things where there are many options, but not too many individual fields.
These are some quite interesting and relevant flags, including:
- Py_TPFLAGS_HEAPTYPE: Indicates that the type object itself is allocated on the heap, characteristic of Python-defined classes.
- Py_TPFLAGS_BASETYPE: Allows the type to be used as a base class for inheritance.
- Py_TPFLAGS_HAVE_GC: Signals that the object supports garbage collection, implying the presence of tp_traverse and tp_clear slots.
- Py_TPFLAGS_IMMUTABLETYPE: Marks the type as immutable, meaning its attributes cannot be set or deleted after creation.
- Py_TPFLAGS_MANAGED_DICT / Py_TPFLAGS_MANAGED_WEAKREF: These flags indicate that the instance's __dict__ or weak references are managed by the CPython VM.
C. Object Lifecycle Management: Allocation, Initialization, and Deallocation Revisited
The lifecycle of a Python object is broken into distinct phases — allocation, initialization, finalization, and deallocation, all of which are controlled through specific slots in the PyTypeObject.
1.LifeCycle
Object Allocation: tp_new (newfunc) , this function is responsible for allocating a new instance, it corresponds to __new__() from Python and typically handles memory allocation and minimal setup (such as setting reference counts and type).
Cool fact is that although the name says “new”, tp_new isn’t always required to return a new object. In some cases like singletons or memoized (Memoization is an optimization technique that uses caching, probably for an entire new article in the future) patterns, it can return a reference to an existing object. This flexibility gives developers precise control over the object identity and instantiation behavior.
Okay, now if you happen to ask what a “singleton” is, it means that you will always get the exact same object no matter how many times you try to create new instances of it, for example None, True and False are such singletons, there’s only one for each of these. It’s a design pattern that restricts the instantiation of a class to a single object.
Object Initialization: tp_init (initproc) , so after tp_new above returns a valid object, tp_init is invoked to perform initialization step, this is where instance attributes are setup and constructor arguments are handled. Yup this is Python’s __init__()
The question came to my mind right away was that “why the separation of these two”, This clear separation means that tp_new decides which object to provide (a brand new one or an existing one, reused one), while tp_init then consistently handles setting up that object’s specific state. So it’s like imagine you have an pool full of objects, tp_new might just grab an object from the pool instead of allocating a fresh one, then the role of tp_init is that even if tp_new returns an existing object, it can still “reset” or rather “re-initialize” its state for its new purpose, therefore this whole separation allows for optimizations such as reusing existing objects while still running initialization logic conditionally.
Object Deallocation and Cleanup: tp_dealloc (destructor) , When an object’s reference count ( ob_refcnt) reaches zero, CPython calls tp_dealloc (Sounds familiar?, yes we discussed about this in 1-B section ). This function is responsible for releasing owned resources, other Python objects, buffers, file handles etc and finally deallocating the object’s memory by calling tp_free .
For non-GC types, tp_free is what usually set to PyObject_Del() . This is consistent with the rule that says: “an object should be deallocated using the same allocator used to create it.” If PyObject_New() was used for allocation, PyObject_Del() should be used to free it.
you will also occasionally see the pattern:
tp_free((PyObject *)self);
Py_DECREF(Py_TYPE(self));
This ensures the type object itself (if heap allocated) can be freed once all instances are gone, since it holds a reference to its type by default.
Raw Memory Release: *tp_free (freefunc) *, this is the actual function that frees the memory allocated for the object. Depending on the type’s GC status and memory model, this is typically:
- PyObject_Del() — simple and for non-GC types
- PyObject_GC_Del() — for objects managed by the cyclic garbage collector
this is called within the tp_dealloc as the final step of destruction.
Optional Finalizer: *tp_finalize (destructor) *, This slot corresponds to __del__() method in Python. If defined tp_finalize is called once before the object is destroyed either by the GC or right before the deallocation.
Since you can call this before an object gets destroyed, you could actually create a new reference to this object, essentially resurrecting the object. But this must be handled with care as it can interfere with deterministic cleanup processes.
Breaking Cycles: tp_clear (inquiry) , This is actually a part of the garbage collector’s cycle breaking phase. It is called specifically to clear references the object holds to other Python objects, especially in case of self referential or mutually referential structures. Note that this is not called by PyDECREF , and is only triggered when CPython’s cyclic GC detects a cycle.
2. Static vs. Heap — Allocated Types
CPython supports two primary ways of defining custom types, with tradeoffs in performance, flexibility and control.
Static Types
Defined as statically allocated PyTypeObject structs in C. These are initialized at module load time via PyTypeReady() and are typically immutable. Static types are more efficient due to lower overhead and are often used for core types or simple extensions.
- No runtime customization
- Limited to single inheritance
- Excellent for performance critical or fixed layout types
Heap Types
Heap allocated types (indicated by Py_TPFLAGS_HEAPTYPE) are created dynamically using functions like PyType_FromSpec() . These match Python’s class model more closely. They are slightly more expensive to manage but are essential for flexibility in user defined classes or framework level abstractions.
- Dynamic creation and modification
- Full multiple inheritance
- Support for metaclasses and descriptors
Finally, aah let’s get our hands dirty.
Code Example: A Minimal Custom Type in C
Don’t worry about setups and errors, as I told at the beginning of the article, you can open this https://colab.research.google.com/drive/1RfLUdQbQWDMxonA5Zidn4teNKxfLYQ6d?usp=sharing link on another tab and see the example in action. This will contain all the three examples that we will have in this article, check the first cell collection tab for this particular one.
If you want to run this on your own machine, the code is available as a GitHub gist as well, so simply download and compile: https://gist.github.com/RezSat/e23d767605c1caa44f48ac4db7548439
The following C code outlines a minimal custom type, SimpleObject. This type exposes a single integer member and provides a basic __repr__ method.
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdio.h> // For PySys_WriteStdout
// 1. Define the instance structure (must start with PyObject_HEAD)
typedef struct {
PyObject_HEAD
long value; // Our custom data member
} SimpleObject;
// 2. Implement tp_dealloc: Frees the object's memory
static void
SimpleObject_dealloc(SimpleObject *self) {
Py_TYPE(self)->tp_free((PyObject *)self); // Call the type's free function
}
// 3. Implement tp_repr: Provides string representation ( __repr__ )
static PyObject *
SimpleObject_repr(SimpleObject *self) {
return PyUnicode_FromFormat("<SimpleObject value=%ld at %p>",
self->value, (void *)self);
}
// 4. Implement tp_new: Creates new instances ( __new__ )
static PyObject *
SimpleObject_new(PyTypeObject *type, PyObject *args, PyObject *kwds) {
SimpleObject *self;
long initial_value = 0; // Default value
// Parse optional initial value
if (!PyArg_ParseTuple(args, "|l", &initial_value)) {
return NULL;
}
// Allocate memory for the object
self = (SimpleObject *)type->tp_alloc(type, 0);
if (self == NULL) {
return NULL;
}
// Initialize our custom member
self->value = initial_value;
return (PyObject *)self;
}
// 5. Define the PyTypeObject structure
static PyTypeObject SimpleType = {
PyVarObject_HEAD_INIT(NULL, 0) // Initializes ob_refcnt and ob_type (set by PyType_Ready)
.tp_name = "simple_module.SimpleObject", // Type name
.tp_basicsize = sizeof(SimpleObject), // Size of instance
.tp_dealloc = (destructor)SimpleObject_dealloc, // Deallocation function
.tp_repr = (reprfunc)SimpleObject_repr, // Representation function
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, // Default flags, allow subclassing
.tp_doc = "A simple custom object with an integer value.", // Docstring
.tp_new = SimpleObject_new, // Object creation function
};
// ---
// Corrected Module method definitions
// Declare SimpleModuleMethods as an ARRAY of PyMethodDef
static PyMethodDef SimpleModuleMethods[] = {
{NULL, NULL, 0, NULL} // Sentinel
};
// ---
// 7. Define module structure
static struct PyModuleDef simplemodule = {
PyModuleDef_HEAD_INIT,
"simple_module", // Module name
"A module defining a simple custom object.", // Module docstring
-1, // Size of per-interpreter state
SimpleModuleMethods // Module methods (now correctly a pointer to the array)
};
// 8. Module initialization function
PyMODINIT_FUNC
PyInit_simple_module(void) {
PyObject *m;
// Finalize the type object
if (PyType_Ready(&SimpleType) < 0) {
return NULL;
}
// Create the module
m = PyModule_Create(&simplemodule);
if (m == NULL) {
return NULL;
}
// Add the custom type to the module
Py_INCREF(&SimpleType); // Add a strong reference for the module
if (PyModule_AddObject(m, "SimpleObject", (PyObject *)&SimpleType) < 0) {
Py_DECREF(&SimpleType); // Decrement if adding fails
Py_DECREF(m);
return NULL;
}
return m;
}
Compilation (Linux/macOS):
gcc -shared -fPIC -I/usr/include/python3.x -o simple_module.so simple_module.c (Replace python3.x with your Python version, ex: python3.10)
Python Usage:
import simple_module
obj1 = simple_module.SimpleObject(100)
print(obj1)
# Expected output: <SimpleObject value=100 at 0x...>
obj2 = simple_module.SimpleObject()
print(obj2)
# Expected output: <SimpleObject value=0 at 0x...>
Let this be a boilerplate for your next CPython custom type. And this marked the end of this main section, and I promise the next section in which we will have two more interactive examples will be the last main section of this article, (cough x2) before the Conclusion :).
4. Advanced Concepts (For me): OOP, GC, and Performance
A. Inheritance and Method Resolution in CPython
CPython’s object model provides a simple but powerful and fast means of inheritance, method dispatch and polymorphism at the C level. While this implementation is much simpler than Python’s high level multiple inheritance, this C layer is optimized for performance and resembles the dynamic behavior of Python with slot based mechanics and linearized resolution paths.
1. C-Level Inheritance: tp_base and PyType_Ready()
At the C level, inheritance is handled through the tp_base slot in the PyTypeObject struct. This pointer references the immediate base type from which the current type inherits.
Initialization is handled by PyType_Ready(). During this process, CPython does something called “ Inheritance-by-copy ”, if a slot in the new type is unset, the value of that slot is copied straight from the PyTypeObject of the base type. For instance, PyType_Ready() will copy tp_repr from its tp_base (typically PyBaseObject_Type ) if it is not defined in the new type.
This copy-based approach is a purposeful performance enhancement. CPython prevents recurrent lookups along the inheritance chain during regular operation by resolving and storing function pointers at initialization time. The drawback is that since the values were set at the time of creation, modifications made to a base type’s slots later on will not immediately affect C-defined subclasses that have already been initialized (since the values were fixed at the time of their creation).
2. Method Resolution Order (MRO): C3 Linearization
Earlier, when looking at attribute access ( 3-A-1 ) (tp_getattro), we saw that Python needs to decide where to look for an attribute. This section is all about that.
At C-level inheritance is single, however CPython supports Python’s multiple inheritance through Method Resolution Order (MRO). MRO is the exact order in which the classes are searched when resolving a method or attribute, especially in complex class hierarchies where ambiguity (like the diamond problem — search for the term it's an issue that is very simple to understand) may occur.
CPython uses the C3 linearization algorithm to compute this order. This algorithm ensures:
- Monotonicity (subclasses adhere to the order of the base class)
- Predictability (regardless of the order in which the method is defined, behavior is consistent)
- Determinism (the same result for the same hierarchy)
The result is stored in the tp_mro slot as a Python tuple of class objects. This slot is not inherited instead it is computed from scratch during PyType_Read().
In python you can do below to inspect the mro:
MyClass. __mro__
MyClass.mro()
When an attribute is accessed, the lookup starts with tp_getattro function. Most user defined types have this slot set to PyObject_GenericGetAttr, which performs the actual traversal of the MRO chain stored in tp_mro
Let’s actually look at what happens internally during this attribute lookup:
First , Traverse the MRO
Second , look for below in each class’s dictionary( tp_dict ):
- Data Descriptors ( tp_descr_get, tp_descr_set )
- Method (from tp_methods )
- Members ( tp_members)
- Get/Set properties ( tp_getset )
Third , look into the instance's own __dict__, if and only if the attribute isn’t found in any class. If the attribute is still not found after this process, tp_getattro raises an AttributeError .
This entire mechanism is implemented with tight, low overhead C logic, and these tp_getattro , tp_mro , tp_dict and the descriptor protocol forms the very foundation of Python’s attribute resolution behavior that we all love and use.
3. Polymorphism at the C Level
Polymorphism as we all know is a very common concept used in Object Oriented Programming (OOP). Polymorphism in Python is achieved through two simple constructs which we already discussed, the ob_type field in every PyObject, and the slot-based design of PyTypeObject.
We already know that every Python object begins with a pointer to its type. This ob_type pointer acts as the dispatch table. Whenever Python needs to perform an operation like calling an object, printing it, or accessing an attribute it simply checks the relevant slot in the object’s type (ex: tp_call for calling an object, tp_getattro to get an attribute)
This means CPython does not need explicit type checks or casting to implement polymorphism. The behavior is resolved at the runtime based on the object’s type. Two completely unrelated types can both implement tp_len or tp_iter and the interpreter will correctly call the appropriate function based on ob_type.
Yes, it's essentially runtime polymorphism, but not implemented with C++ style virtual tables or class hierarchies rather in pure C with lean, predictable mechanism built directly into CPython’s object model. This slot dispatch design makes the system compact and extensible with high performance and is perfectly aligned with Python’s dynamic nature.
B. Garbage Collection: Tackling Reference Cycles
1. The problem of Reference Cycles
Reference counting is simple and efficient, however it cannot resolve circular references. A circular reference occurred when two or more objects hold references to each other in a closed loop. In such cases, the ob_refcnt of each object never reaches zero, even if there is no external references. This makes them permanently unreachable from the program but never freed from memory.
for example:
a = []
b = []
a.append(b)
b.append(a)
In here, a references b and b references a . Even if we delete both names with del a,b the two lists still keep each other alive, and their reference counts remain above zero effectively creating memory leaks.
These becomes really annoying in long running applications like servers, where memory leaks accumulate over time. Python therefore supplements reference counting with an additional mechanism which is called the “Cyclic Garbage Collector”.
2. CPython’s Cyclic Garbage Collector
This Cyclic Garbage Collector is implemented in Modules/gcmodule.c (The gc module in Python). The garbage collector unlike the reference counting ( which runs continuously and deterministically), operates periodically and focuses only on objects that are containers (lists, dictionaries, sets and user defined objects that form cycles)
Periodically, the GC scans these objects, looking for groups that only reference each other and when such groups are found and proven unreachable, they will be reclaimed.
This is done with a “generational” algorithm (means it organizes into three groups based on their “age” / “how long it was alive”):
Generation 0: Newly allocated container objects.
Generation 1: Objects that survived one collection.
Generation 2: Long lived survivors that survived multiple rounds.
Most of these objects die early, therefore Generation 0 is collected most frequently, while older generations are scanned less often to reduce overhead.
Now, if you’re thinking, “I don’t trust the defaults to manage my memory,” you’re in luck because Python actually lets you peek into and control these processes.
Working with the gc Module in Python
- Forcing a collection
- we can force an immediate garbage collection. This can be useful in memory constrained environments where you need to free up memory at some point.
import gc
#Force a full collection
gc.collect()
2. Checking for uncollectable objects
- when gc can’t free objects that are part of a cycle, particularly if they have __del__ methods
import gc
# Force a collection
gc.collect()
# Lists objects that the GC could not free
print(gc.garbage)
3. Inspecting objects in memory
- Checking list of objects that is currently being tracked by the gc
import gc
objs = gc.get_objects() #function returns a list of all objects
# Print the number of objects being tracked
print(f"Currently tracking {len(objs)} objects")
4. Disabling and Enabling the gc
- Can be useful for debugging
import gc
# Disable the cyclic garbage collector
gc.disable()
# ... your code here ...
# Enable the cyclic garbage collector
gc.enable()
5. Tuning collection thresholds
- This is a fun one, gc uses thresholds to determine when to run. We can inspect and adjust these value to fine tune the behavior of the collector. Thresholds represent the number of new allocation in each generation before a collection is triggered.
import gc
# (700, 10, 10) by default
print(gc.get_threshold())
# Adjust the collection thresholds
gc.set_threshold(1000, 15, 15)
# Print the current number of new objects in each generation
print(gc.get_count())
Getting your hands down to the gc module is really cool, and fine tuning these thresholds can help performance in workloads that create many short lived objects. Its not something most Python developers need every day, but could be useful when debugging memory leaks or working in specialized environments.
Reference Counting and Cyclic Garbage Collection format the very backbone of CPython’s memory management, its just beautiful.
C. Inspecting CPython Internals: Hands-On Examples
1. Code Example: Inspecting PyObject in C
Examples here can be seen and run as mentioned before using this link : https://colab.research.google.com/drive/1RfLUdQbQWDMxonA5Zidn4teNKxfLYQ6d?usp=sharing
To illustrate the fundamental PyObject structure and its accessible fields, consider the following C extension. This example creates a Python integer object and then inspects its internal ob_refcnt and ob_type->tp_name fields using the CPython API macros.
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdio.h> // For PySys_WriteStdout
// Define a simple C function that creates a Python integer
// and prints its ob_refcnt and ob_type->tp_name
static PyObject*
inspect_int_object(PyObject *self, PyObject *args) {
PyObject *py_int_obj;
long value;
// Parse arguments: expect a single long integer
if (!PyArg_ParseTuple(args, "l", &value)) {
return NULL; // Error occurred
}
// Create a Python integer object
py_int_obj = PyLong_FromLong(value);
if (py_int_obj == NULL) {
return NULL; // Error creating object
}
// Access ob_refcnt and ob_type using macros
Py_ssize_t ref_count = Py_REFCNT(py_int_obj);
PyTypeObject *type_obj = Py_TYPE(py_int_obj);
const char *type_name = type_obj->tp_name;
// Print the information
PySys_WriteStdout("Object: %ld\n", value);
PySys_WriteStdout(" Reference Count: %zd\n", ref_count);
PySys_WriteStdout(" Type Name: %s\n", type_name);
// Decrement the reference count for the object we created
// This is crucial for proper memory management.
Py_DECREF(py_int_obj);
Py_RETURN_NONE; // Return None (increments its refcount)
}
// Module method definitions
static PyMethodDef MyInspectMethods = {
{"inspect_int", inspect_int_object, METH_VARARGS,
"Inspects a Python integer object's internal PyObject fields."},
{NULL, NULL, 0, NULL} // Sentinel
};
// Module definition structure for Python 3
static struct PyModuleDef inspectmodule = {
PyModuleDef_HEAD_INIT,
"inspect_internals", // Module name
"A module to inspect CPython object internals.", // Module docstring
-1, // Size of per-interpreter state of the module, -1 means global state
MyInspectMethods // Module methods
};
// Module initialization function
PyMODINIT_FUNC
PyInit_inspect_internals(void) {
return PyModule_Create(&inspectmodule);
}
Compilation (Linux/macOS):
gcc -shared -fPIC -I/usr/include/python3.x -o inspect_internals.so inspect_internals.c
(Replace python3.x with your Python version, e.g., python3.10)
Python Usage:
import inspect_internals
inspect_internals.inspect_int(42)
# Expected output (reference count may vary for small integers due to memoization):
# Object: 42
# Reference Count: 1 (or 2 if sys.getrefcount() is used, or higher for memoized small ints)
# Type Name: int
2. Low-Level Access with ctypes in Python
As an illustrative example, one can even use Python’s ctypes library to peek at an object’s header. Since id(obj) in CPython returns the memory address of the object, we can cast it to a ctypes pointer.
For instance:
import ctypes, sys
lst = []
# Get reference count from the object header:
refcnt = ctypes.c_ssize_t.from_address(id(lst)).value
print("C ctypes says refcount =", refcnt)
print("sys.getrefcount says refcount =", sys.getrefcount(lst))
Output:
C ctypes says refcount = 1
sys.getrefcount says refcount = 2
D. Performance Considerations and Common Pitfalls
Unlike working at Python level, when working with CPython’s internals like through C extensions, developers have to deal with performace trade offs and memory management issues.
1. GIL’s Role in Reference Counting and Thread Safety
Global Interpreter Lock or GIL for short is a process-wide mutex ensuring that ony one thread executes Python bytecode at any given time. Its main role is to protect shared internal data structures, most notably the ob_refcnt field of every Python object.
If not for GIL, each Py_INCREF and Py_DECREF would require atomic instructions or fine-grained locks across millions of objects, which will add immense complexity and runtime cost. The GIL therefore represents a deliberate engineering choice which is to sacrifice parallelism in exchange for simplicity and robustness of the memory model.
2. Impact on Concurrency and CPU-Bound tasks
Because of the GIL, Python threads cannot achieve true multi-core parallelism for CPU bound tasks, even on a 32 core machine, only one thread can run bytecode at a time.
But this limitation is somewhat less severe for I/O bound workloads. The interpreter releases the GIL during blocking I/O (network, disk, sockets), allowing other threads to make progress, this is why multi-threading remains viable for I/O concurrency in Python, even if it underperforms for CPU-heavy tasks.
So in practice:
- CPU bound work — use multiprocessing or C extensions
- I/O bound work — multi threading will work fine.
3. The Road Ahead: PEP 703 and No-GIL CPython
Python is now introducing an experimental no-GIL build, it requires a deep re-engineering of CPython’s internals.
There experimenting on some techniques to move the interpreter from GIL’s bruteforce simplicity towards a scalable, multi-core aware memory model but without breaking the semantics of billions of lines of python code.
4. Reference Leaks vs Reference Deficits
Two of the most common failure modes are reference leaks and reference deficits, which we discussed roughly in above chapters
- Reference Surplus (Leak): Forgetting to Py_DECREF an owned reference. The object stays alive forever, and long running processes slowly balloon in memory. Often silent and insidious.
- Reference Deficit (Premature Free): Calling Py_DECREF too often. The object is deallocated too early, leading to ‘use-after-free’ bugs and segmentation faults. These are explosive and harder to debug, as the crash may occur far away from the original mistake.
Best Practices or C API Memory Management
When writing extensions, a strict discipline is essential.
- Respect Ownership Semantics: Always check if a function returns a new reference (you own it) or a borrowed reference (you don’t). This is the single most important rule.
- Error Handling Discipline: On error, functions usually return NULL but may have created intermediate objects. Clean them up properly before returning.
- Helper Macros Save Lives: Use Py_XINCREF , Py_XDECREF , Py_NewRef , Py_CLEAR and Py_SETREF . These are not optional style points, they encode decades of hard-earned safety patterns.
- Weak References: Use weakrefs ( Py_TPFLAGS_MANAGED_WEAKREF, PyObject_ClearWeakRefs ) for cache like structures that should not prevent GC.
- Container Hygiene: If you implement container types in C, adjust the refcounts of contained elements meticulously.
Conclusion
So, what to take from all of this? CPython’s object model might look intimidating at first glance, but once you peel back the layers it’s actually a very neat system. Everything can be explained in few consistent ideas: objects carry their own type and reference count, types describe behavior through slots, and reference management ensures memory stays in check.
Whether you’re just curious, writing a C extension or thinking about contributing for CPython itself, understanding these internals gives you a whole new perspective on Python.
At the end of the day, you don’t need to memorize every slot or flag. What matters is grasping the philosophy; Python’s flexibility at the surface is built on a surprisingly strict, predictable C core. Once you know these, you can write better Python code, debug smarter and maybe even push the language forward yourself.
Yup, that marks the end of this article. Took me about a month to write, hope it gives you something useful :).

Top comments (0)