How Python Implements Dynamic Typing Under the Hood

Python achieves dynamic typing by treating variables as lightweight references to heap-allocated objects rather than fixed, typed memory locations. Under the hood, particularly within the standard CPython implementation, every value is encapsulated inside a generic C structure called PyObject, which carries both the data and the explicit metadata defining its type. This architecture allows Python to resolve types, inspect capabilities, and dispatch operations entirely at runtime, freeing developers from declaring variable types ahead of time.

Variables as Pointers, Not Memory Containers

In statically typed languages like C or Rust, a variable declaration allocates a specific block of memory designed to hold a specific binary representation (such as 4 bytes for a 32-bit integer). In contrast, a Python variable is simply a name bound to a pointer.

When you write x = 42, Python does not allocate an integer-sized slot labeled x. Instead, it allocates an integer object on the heap and points the name x in the local namespace dictionary to that object's memory address. If you later execute x = "hello", Python simply points x to a newly allocated string object. The variable x has no type of its own; only the object it points to possesses a type.

The Foundation: PyObject and ob_type

At the C level, every Python object shares a common header defined by the PyObject structure. This structure contains two critical fields:

typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;
  1. ob_refcnt: An integer tracking the number of active references to the object, used by Python's reference-counting garbage collector.
  2. ob_type: A pointer to a PyTypeObject. This pointer is the cornerstone of dynamic typing.

Because every object begins with these fields, the CPython runtime can treat any pointer to any Python object as a generic PyObject*. When Python needs to know what an object is or what it can do, it follows the ob_type pointer to inspect the type's definition.

The Role of PyTypeObject

The PyTypeObject struct serves as the blueprint for an object's behavior. It contains metadata such as the type's name, its allocation size, and tables of function pointers known as suites or method tables (such as tp_as_number, tp_as_sequence, and tp_as_mapping).

These function pointers dictate how the object responds to language-level operations:

When an operation is executed, Python does not rely on compile-time assertions. Instead, it inspects the ob_type of the operand at runtime, verifies whether the requested slot contains a valid function pointer, and executes that function.

Dynamic Dispatch and Duck Typing

Because operations depend on the function pointers inside ob_type, Python natively supports duck typing. When evaluating an expression like a + b, the interpreter does not verify that a belongs to a specific class hierarchy.

Instead, the execution follows these steps:

  1. The interpreter reads the ob_type of a.
  2. It checks if a->ob_type->tp_as_number->nb_add is defined.
  3. If defined, it calls that function with a and b as arguments.
  4. If the operation returns NotImplemented or the slot is null, the interpreter checks if b provides a reverse operation (nb_radd).
  5. If neither operand provides a valid implementation, Python raises a runtime TypeError.

This process eliminates the need for explicit type matching at compile time, resolving compatibility solely based on an object's runtime interface.

The Cost of Dynamic Typing and Modern Optimizations

Dynamic typing introduces performance overhead compared to static compilation. Every basic operation requires multiple pointer dereferences (fetching the type object, looking up the method table, and calling the function pointer) alongside boxing primitive values into heap-allocated objects.

To minimize this overhead, CPython implements several internal optimizations: