When we think about running Python code, particularly in the CPython interpreter, we normally think about interpreted code (a bytecode interpreter), but we also know that we can write native modules (extension modules) normally in C or C++. but also using cython, nuitka or even rust. These extension modules are not used just by some third party libraries, but the python runtime/environment/standard library also makes good use of them. In this sense, as explained in this article we have 2 types of these modules:
- A built-in extension module is a module built and shipped with the Python interpreter. A built-in module is statically linked into the interpreter, thereby lacking a __file__ attribute.
- A shared (or dynamic) extension module is built as a shared library (.so or .dll file) and is dynamically linked into the interpreter. In particular, the module’s __file__ attribute contains the path to the .so or .dll file.
Normally the Python interpreter is contained in the python3.XX binary (.exe in Windows), that is like 30 MBs in size, but in some builds (those done with the --enable-shared flag), most of the code is put in a libpython3.14.so (.dll in Windows) dynamic libray, and the python binary just bootstraps it.
built-in extension modules (like sys, builtins, _thread, gc...) are obviously part of the Python distribution (they are inside the interpreter as we've seen), while for shared extension modules, we have those that are part of the python distribution (like math, array, _ssl, _socket) and those that are developed by third parties.
You can check all the built-in extension modules like this:
>>> sys.builtin_module_names
('_abc', '_ast', '_codecs', '_collections', '_contextvars', '_datetime', '_functools', '_imp', '_io', '_locale', '_opcode', '_operator', '_signal', '_sre', '_stat', '_string', '_suggestions', '_symtable', '_sysconfig', '_thread', '_tokenize', '_tracemalloc', '_types', '_typing', '_warnings', '_weakref', 'atexit', 'builtins', 'errno', 'faulthandler', 'gc', 'itertools', 'marshal', 'posix', 'pwd', 'sys', 'time')
While for the shared extension modules that are part of your distribution, just check the lib-dynload folder in your installation, e.g.:
ls -la /usr/local/lib/python3.14/lib-dynload/ total 27032 drwxr-xr-x 2 root root 4096 mar 14 13:26 . drwxr-xr-x 43 root root 4096 mar 14 13:26 .. -rwxr-xr-x 1 root root 298704 mar 14 13:26 array.cpython-314-x86_64-linux-gnu.so -rwxr-xr-x 1 root root 399192 mar 14 13:26 _asyncio.cpython-314-x86_64-linux-gnu.so -rwxr-xr-x 1 root root 201048 mar 14 13:26 binascii.cpython-314-x86_64-linux-gnu.so -rwxr-xr-x 1 root root 97240 mar 14 13:26 _bisect.cpython-314-x86_64-linux-gnu.so -rwxr-xr-x 1 root root 1705384 mar 14 13:26 _blake2.cpython-314-x86_64-linux-gnu.so -rwxr-xr-x 1 root root 105048 mar 14 13:26 _bz2.cpython-314-x86_64-linux-gnu.so -rwxr-xr-x 1 root root 166800 mar 14 13:26 cmath.cpython-314-x86_64-linux-gnu.so ...
So when running a Python application you have normal Python functions (that as first step of the execution have been compiled to Python bytecodes) that have to be interpreted, and other functions, living in those extension modules that are already native code and have to be executed as such, without interpretation. How does Python manage that?
The essential part in most interpreters is the interpreter loop. This is the code that loops through the bytecode instructions to be interpreted (or traverses the tree of nodes in Tree-parsing interpreters). I say "most" rather than "all" because in tree parsing interpreters we can have things like Truffle, where each node execute() method takes care of moving to the next node (and also manages its own specialization). In CPython, this interpreter loop lives in the _PyEval_EvalFrameDefault function. It's nicely explained here
When a Python function is invoked we have a Function object and a CALL bytecode instruction. If the function being called is not a native one (so a normal function that was written in pure Python and compiled to bytecodes to be interpreted) the interpreter handles this CALL by creating a frame object (the optimized _PyInterpreterFrame that I discussed here), that contains all the information needed for the function execution: local variables (including arguments and closure cells), the code object... and invokes _PyEval_EvalFrameDefault() with that frame. Indeed, there's a very interesting performance optimization added in Python3.11, _PyEval_EvalFrameDefault no longer recursively calls itself when it finds a new CALL, so we keep a flat C stack. From a GPT:
Ordinary Python→Python calls are frameless on the C stack. A chain of plain function calls runs inside a single _PyEval_EvalFrameDefault C invocation. The CALL opcode pushes a lightweight _PyInterpreterFrame onto a per thread data stack (chunked heap) and dispatches within the same C frame. Plain recursion is bounded by sys.getrecursionlimit() (a logical / data-stack counter), not the C stack.
And from this very in depth article
In CPython 3.10 and earlier, the CALL instruction used to create a new interpreter stackframe for the function being called and then it used to recursively reenter the interpreter by calling its entry point _PyEval_EvalFrameDefault.
This was bad for performance from many angles at the hardware level. The recursive call into the interpreter required saving the registers for the current function, and pushing a new C stackframe. It would lead to increased memory usage because each recursive interpreter call would allocate its own local variables on the stack, and other heap allocations. Apart from that it would also lead to poor instruction cache locality due to the constant jumps in and out of the bytecode evaluation loop.
In the 3.11 release this was fixed by eliminating the recursive call to the interpreter. Now the CALL instruction simply creates the stackframe for the called function, after that it immediately starts evaluating the new function’s bytecode without ever leaving the loop.
I've explained so far how the interpreter manages "normal" functions, but what about native functions? The Function object for a native function does not have a code object (as obviously they don't have associated bytecodes), but a function pointer to the native code. From a GPT:
Native functions: Stored as PyCFunctionObject with a function pointer
No __code__ attribute
Instead, has a function pointer (ml_meth) stored in PyMethodDef
CPython calls the C function pointer directly, bypassing the interpreter loop
The C function executes natively and returns a PyObject*
How does the interpreter check if this 'CALL function_object' should be treated one way or another (_PyInterpreterFrame + _PyEval_EvalFrameDefault() vs native call)? Basically at a low level different objects are used for a "normal" function and a native function. But the whole story goes like this (GPT explanation)
At the C level, all Python objects are represented by the PyObject structure, which points to a PyTypeObject (its type). The type object defines how instances of that type behave.
To make calls fast and avoid creating temporary tuple/dict arguments, CPython uses the vectorcall protocol (Py_TPFLAGS_HAVE_VECTORCALL).
When the interpreter encounters a CALL opcode, it ultimately looks at the target object's type to find its vectorcall entry point:
- Pure Python Functions (PyFunction_Type): The vectorcall pointer points to _PyFunction_Vectorcall. This function extracts the function's PyCodeObject (func->func_code), allocates a new _PyInterpreterFrame on the evaluation stack, and pushes it to _PyEval_EvalFrameDefault.
- Built-in/Native Functions (PyCFunction_Type): Native functions (like math.sqrt or print) are wrapped in a PyCFunctionObject. Their vectorcall pointer points to _PyCFunction_Vectorcall. This function extracts the underlying C function pointer (func->m_ml->ml_meth) and invokes the compiled C code directly.
But things are even more interesting. I already talked about how in Python3.11 the Python interpreter turned into a Python Adaptive Specializing Interpreter. Thanks to that, Function Calls are optimized like this (as explained by a GPT)
If the interpreter had to look up the type and vectorcall pointer from scratch on every single loop iteration, it would be slow. To solve this, CPython uses Opcodes Specialization (PEP 659 Adaptive Interpreter).
When a generic CALL opcode executes, it starts in an "adaptive" state. It looks at the object being called and patches itself in memory to a specialized version based on what it sees:
- Python-to-Python Calls
If the target is a pure Python function, the CALL opcode specializes itself into CALL_PY_EXACT_ARGS (or CALL_PY_BOUND_METHOD).
The Check: It performs a strict pointer comparison on the target's type to ensure it is exactly PyFunction_Type.
The Action: It skips the generic lookup, directly grabs the code object, creates the _PyInterpreterFrame, and increments the frame depth.
- Python-to-Native Calls
If the target is a native C function, the CALL opcode specializes into CALL_BUILTIN_FAST or CALL_BUILTIN_CLASS.
The Check: It verifies that the object's type is PyCFunction_Type and often checks if the specific function handler matches what was cached.
The Action: It bypasses the interpreter frame creation entirely, sets up the C arguments, and jumps straight into the native C function.
That's fascinating!
If out of curiosity we want to know if a function is pure Python or native code we can check its type. For normal functions its type is FunctionType, for native functions it's BuiltinFunctionType
# Normal functions:
import types
import math
def f1(): pass
l1 = lambda x: x
isinstance(f1, types.FunctionType)
True
isinstance(l1, types.FunctionType)
True
isinstance(len, types.FunctionType)
False
isinstance(len, types.BuiltinFunctionType)
True
isinstance(math.sqrt, types.BuiltinFunctionType)
Additionally, as I've mentioned, native functions lack a __code__ attribute.
f1.__code__
code object f1 at 0x76be17bfcac0, file "", line 1
len.__code__
Traceback (most recent call last):
File "", line 1, in
len.__code__
AttributeError: 'builtin_function_or_method' object has no attribute '__code__'.