Under the Hood of Python's id() Function: CPython Internals and Pointer Semantics

Arpit Bhayani

Arpit Bhayani

May 24, 2021 • 6 min read

Play

Under the Hood of Python’s id() Function: CPython Internals and Pointer Semantics

In Python, the built-in id() function returns an object’s identity. While developers regularly use it to verify object identity or understand mutability, its internal implementation reveals fundamental mechanics of CPython’s memory management and runtime execution.

According to the official Python documentation:

“Return the ‘identity’ of an object. This is an integer which is guaranteed to be unique and constant for this object during its lifetime. Two objects with non-overlapping lifetimes may have the same id() value. CPython implementation detail: This is the address of the object in memory.”

Examining CPython’s source code shows how this invariant is enforced, why IDs can be recycled, and how Python bridges C pointers with Python integer objects.


1. The Core Invariants of id()

The Python language specification imposes strict requirements on the id() function:

  1. Uniqueness: No two concurrently existing objects can have the same identifier.
  2. Constancy: An object’s identity remains unchanged from creation until destruction.
  3. Non-Overlapping Lifetime Reusability: Once an object is garbage-collected or explicitly deleted, its identifier can be reused by a newly allocated object.

Why Can IDs Be Reused?

Because CPython defines id(obj) as the object’s virtual memory address in RAM, ID reuse is a direct consequence of memory allocation mechanics:

[Step 1] Allocate Object A  ───> malloc() assigns address 0x10A8C0
[Step 2] id(A) = 1091776   ───> (Decimal representation of 0x10A8C0)
[Step 3] del A             ───> Garbage collector deallocates 0x10A8C0
[Step 4] Allocate Object B  ───> malloc() reallocates free slot 0x10A8C0
[Step 5] id(B) = 1091776   ───> Same ID, but non-overlapping lifetime

When object A is deallocated, its memory block is returned to the allocator (e.g., Python’s pymalloc arena or system malloc). If a new object B requests a block of the same size, the memory allocator may grant that exact address. Thus, id(A) == id(B), even though they represent completely distinct logical entities at different points in time.


2. Navigating the CPython Source Code

To see how this is implemented, we can trace through the CPython code base.

Module and Function Naming Conventions

CPython adheres to specific conventions for mapping modules and C source files:

  • Standard modules implemented in C often end with module.c (e.g., sysmodule.c defines the sys module).
  • Functions within a module are prefixed with the module name: modulename_functionname.

Built-in functions available in the global namespace (like len(), print(), range(), and id()) belong to the builtins module. In the source tree, this file is named bltinmodule.c (shortened from builtinsmodule.c).

Following the naming convention, the C function implementing id() is builtin_id.


3. Deconstructing builtin_id

Inside bltinmodule.c, builtin_id has the following signature and implementation:

static PyObject *
builtin_id(PyModuleDef *self, PyObject *v)
{
    return PyLong_FromVoidPtr(v);
}

How It Works:

  1. Function Arguments:
    • self: The module reference (builtins).
    • v: A pointer to the target PyObject passed from Python code (e.g., passing a to id(a)).
  2. Execution:
    • v is a raw C pointer to the struct representing the object in memory.
    • builtin_id calls PyLong_FromVoidPtr(v) and immediately returns its result.

4. Pointer Conversion: PyLong_FromVoidPtr

In C, a pointer variable holds a memory address. To expose this address to Python, CPython must cast the pointer to an unsigned integer and package it inside a Python integer (PyLongObject).

Looking at PyLong_FromVoidPtr:

PyObject *
PyLong_FromVoidPtr(void *p)
{
#if SIZEOF_VOID_P <= SIZEOF_LONG
    return PyLong_FromUnsignedLong((unsigned long)(uintptr_t)p);
#else
    #if SIZEOF_VOID_P <= SIZEOF_LONG_LONG
        return PyLong_FromUnsignedLongLong((unsigned long long)(uintptr_t)p);
    #else
        #error "void* larger than long long"
    #endif
#endif
}

Step-by-Step Conversion Flow:

graph LR
    A[PyObject* v] -->|void* cast| B[void* p]
    B -->|uintptr_t cast| C[Unsigned Integer]
    C -->|PyLong_FromUnsignedLong| D[PyLongObject]
    D -->|Return to Python| E[Python Integer ID]
  1. uintptr_t Cast: Guarantees that the pointer is safely cast to an integer type capable of holding an address without truncation on the host architecture (32-bit vs. 64-bit).
  2. PyLong_FromUnsignedLong: Instantiates a new Python int object (PyLongObject) containing the integer representation of the raw address.
  3. Result: Python code receives a standard integer that matches the exact physical/virtual RAM address of the object.

5. Verifying the Implementation by Modifying CPython

To verify this behavior, we can inject debug statements directly into builtin_id inside bltinmodule.c:

static PyObject *
builtin_id(PyModuleDef *self, PyObject *v)
{
    printf("[DEBUG] Pointer Hex Address : %p\n", (void *)v);
    printf("[DEBUG] Pointer Decimal Value: %lu\n", (unsigned long)(uintptr_t)v);
    
    return PyLong_FromVoidPtr(v);
}

Recompiling CPython using make and executing standard Python commands verifies the output:

>>> a = 10
>>> id(a)
[DEBUG] Pointer Hex Address : 0x7f8a3c552560
[DEBUG] Pointer Decimal Value: 140231552563552
140231552563552

Converting the hex address 0x7f8a3c552560 to decimal yields 140231552563552, which identically matches the return value of id(a).


6. Implementation Nuances Across Alternative Python Runtimes

While this behavior holds for CPython, other Python implementations handle identity differently:

RuntimeImplementation StrategyReason
CPythonRaw memory address (void*)Objects in CPython do not move in memory once allocated (no compacting GC).
PyPyUnique sequential integer or object indexPyPy uses a moving/compacting garbage collector; memory addresses change over time during garbage collection sweeps.
Jython / IronPythonObject hash code / runtime identity counterBuilt on JVM/.NET CLR where memory addresses are managed and rearranged by the host VM runtime.

Because moving garbage collectors shift objects in physical memory to defragment the heap, using a raw memory address would violate Python’s rule that an object’s ID must remain constant throughout its lifetime. In CPython, objects are never moved once allocated, making memory addresses a direct, performant fit.


Key Takeaways

  • In CPython, id(obj) is literally the memory address of the C struct representing the object (PyObject *v).
  • Built-in functions like id() are defined in bltinmodule.c as builtin_id.
  • PyLong_FromVoidPtr converts the C pointer to an unsigned integer, creating an immutable Python int object (PyLongObject).
  • Address reuse by the memory allocator explains why two objects with non-overlapping lifecycles can produce identical id() values.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses