Skip to content

Language internals & the CPython runtime

Senior

Language vs implementation, bytecode compilation, what CPython stores per object, stack vs heap, shared methods, and what unpacking and swaps compile to.

Created 2 September 2026 · Updated 23 October 2026

8 concepts · detail hidden

  1. 01"Python" the language vs "CPython" the implementation — why the distinction matters

    “Python” the language specification says nothing about a GIL at all — it’s an implementation detail of CPython specifically, not a fundamental language-level requirement. PyPy, Jython, IronPython, and GraalPython each make their own, different choices about interpreter internals and threading, and CPython’s GIL is simply one implementation’s particular tradeoff, not a property every Python implementation is obligated to share. The flat, blanket statement “Python has a GIL” conflates the language with its most common implementation.

    #c-python-the-language-vs-cpython-the-implementation-why-the-di
  2. 02Is Python compiled or interpreted — bytecode compilation then interpretation in CPython

    CPython genuinely compiles Python source code down to an intermediate bytecode representation first (cached on disk as .pyc files for reuse) — and then that bytecode is what actually gets interpreted, step by step, by the CPython virtual machine at runtime. So it’s accurately neither “purely interpreted” (there genuinely is a real compilation step involved) nor “compiled” in the more traditional sense of producing native machine code ahead of time (unlike PyPy’s JIT, which genuinely does eventually produce real machine code for identified hot paths). The precise, accurate framing is “compiled to an intermediate bytecode, which is then interpreted” — distinct from either of the two simpler, less accurate extremes.

    #c-is-python-compiled-or-interpreted-bytecode-compilation-then-
  3. 03What CPython actually stores in an object — beyond just its attributes

    Every Python object, no matter how simple, begins with a fixed-size C struct header, PyObject, carrying at minimum two fields: ob_refcnt, the reference count the GC section above relies on, and ob_type, a pointer to the object’s type/class — exactly what type(obj) reads back. Variable-size objects (list, tuple, str, and friends) extend this with PyVarObject, adding ob_size (how many elements it holds), since C code needs that count available without inspecting Python-level content. Beyond that fixed C-level header, an ordinary instance holds a pointer to its own per-instance state — normally a __dict__ (see “an instance’s __dict__ vs a class’s __dict__” below) unless the class defines __slots__, in which case a fixed slot array replaces it. Methods are not part of that per-instance state at all — they live exactly once on the class’s own __dict__ and are found via the type pointer plus an MRO walk at access time, then wrapped into a short-lived bound-method object per access rather than being stored on the instance (see “are an object’s methods duplicated per instance” in the OOP & runtime model subtopic for the full mechanism, contrasted with Java). So the honest inventory of what a plain instance actually carries is: a refcount, a type pointer, and a dict or slot array holding just that instance’s own attribute values — the methods, the class hierarchy, and (if enabled) the __weakref__ list all live at the class level, not duplicated per object.

    #c-what-cpython-actually-stores-in-an-object-beyond-just-its-at
  4. 04What determines whether an object is stack- or heap-allocated

    In CPython, this question has a blunt answer: every Python object, without exception, is heap-allocated. There is no language- or interpreter-level mechanism for putting an object’s actual data on the stack — the “stack” that exists at the C level only ever holds frame-local pointers to heap objects, plus small C-level bookkeeping (return addresses, saved registers), never the Python objects themselves. This is a direct consequence of CPython’s object model: every object needs a reference count that any number of names, containers, or C-level references can independently hold onto and later release, and Python’s dynamic typing means any name can be rebound to any object at any time — the interpreter generally can’t prove, the way a static compiler or a tracing JIT can, that a given object’s lifetime is safely confined to one function call. Statically- or JIT-compiled runtimes can make this determination — the general technique is called escape analysis: an object can be safely stack-allocated only if the compiler/JIT can prove it never “escapes” the scope that created it (never returned, never stored in a field, never handed to another thread) — the JVM’s HotSpot JIT does this routinely for short-lived objects in hot methods. CPython’s own JIT-adjacent implementation, PyPy, applies a related optimization for some short-lived, provably-local objects inside traced loops — but that’s a PyPy-specific runtime optimization, not something CPython does, and not a language guarantee you can rely on when reasoning about ordinary Python code.

    #c-what-determines-whether-an-object-is-stack--or-heap-allocate
  5. 05Stack vs heap — the core differences

    Stack: a fixed, per-thread region, allocated and reclaimed in strict LIFO order as functions are called and return — pushing/popping a frame is just moving a stack pointer, making allocation essentially free. Its size is comparatively small and fixed up front (commonly around a megabyte per thread by default on many platforms), so recursing too deeply overflows it — the same underlying limit sys.setrecursionlimit exists to catch gracefully before it happens at the C level, as covered under “no tail-call optimization” in the data-structures subtopic. Stack storage is automatically reclaimed the instant a frame returns, with no separate bookkeeping needed.

    Heap: a single region shared across the whole process, managed by a general-purpose allocator (or, in a garbage-collected runtime, by that runtime’s own memory manager) — allocating and freeing requires real bookkeeping to find and return free blocks, so it’s meaningfully more expensive per-operation than a stack push. A heap object’s lifetime is independent of any one function call: it lives until it’s explicitly freed, or — in a reference-counted/garbage-collected language like Python — until nothing references it anymore. Its practical size limit is whatever system memory is available, not a small fixed per-thread budget, and it can fragment over time as objects of varying sizes are allocated and freed in different orders.

    In everyday Python conversation, “the stack” usually really means the call stack — the chain of frames shown in a traceback — not a place where your actual data lives; the data those frames reference is, again, always on the heap.

    #c-stack-vs-heap-the-core-differences
  6. 06Are an object's methods duplicated per instance, or shared — Python vs Java

    Neither language duplicates method code per instance; in both, it lives exactly once, at the class level. In Python, a function defined in a class body becomes a single entry in that class’s own __dict__ (a mappingproxy shared by every instance — see “an instance’s __dict__ vs a class’s __dict__”). Calling instance.method() doesn’t find a copy of method sitting on the instance; ordinary attribute lookup walks from the instance’s own __dict__ (which has no such entry) out to the class’s __dict__ via the MRO, finds the plain function there, and — because a plain function is a non-data descriptor — its __get__ wraps it into a small, transient bound-method object on the fly, binding instance as the implicit first argument for that one call. That wrapper is cheap to create and immediately discardable; nothing about the method itself is copied onto the instance. Java takes a structurally similar approach for a different reason: method bytecode is loaded once per class (into the JVM’s method area/metaspace), never per object; each heap-allocated instance stores only its own field values plus — for methods that can be overridden — a hidden pointer to a per-class virtual-method table (“vtable”) that lets a call site resolve, at runtime, which actual overridden implementation to invoke for that object’s concrete runtime type. So both languages converge on the same underlying principle — code is shared once per class — and the only thing that varies, or is even looked at, per instance is a small piece of dispatch machinery: a bound-method wrapper in Python, a vtable pointer in Java.

    #c-are-an-objects-methods-duplicated-per-instance-or-shared-pyt
  7. 07`*args`/`**kwargs` and `*`/`**` unpacking at the bytecode level

    At the calling side, * and ** trigger dedicated bytecode instructions that unpack an iterable/mapping directly into individual positional/keyword arguments for the call, rather than passing the container itself as a single argument. At the function-definition side, *args/**kwargs instead trigger the reverse: the interpreter collects any excess positional arguments into a genuine tuple bound to args, and any excess keyword arguments into a genuine dict bound to kwargs.

    #c-argskwargs-and-unpacking-at-the-bytecode-level
  8. 08Why `a, b = b, a` works without a temp variable — the tuple on the bytecode stack

    The right-hand side b, a is fully evaluated first, building a temporary tuple (b, a) on the interpreter’s value stack — only after that tuple is completely constructed does unpacking assignment proceed, assigning its elements to a and b respectively — so there’s genuinely no risk of one assignment clobbering a value the other still needs, since both original values were already safely captured into that tuple before either name gets reassigned.

    #c-why-a-b-b-a-works-without-a-temp-variable-the-tuple-on-the-b