CPython's function Implementation in C

A `def` statement builds a PyFunctionObject: code object, globals, defaults, closure cells, and a vectorcall slot. Here's what CPython actually stores.

TL;DR: A def statement hands you a PyFunctionObject: shared bytecode on one side, runtime binding (globals, defaults, closure) on the other. Calls skip tp_call entirely and go straight through _PyFunction_Vectorcall into _PyEval_Vector.

Every def you write produces a function object — types.FunctionType — that binds a piece of bytecode to a globals dict, plus optional defaults, keyword defaults, closure cells, and annotations. In CPython that lives in Objects/funcobject.c as PyFunctionObject.

This is one of those designs that looks like a struct dump until you realize the split is the whole trick: code is shared, binding is not.

The object struct

Every function object is a PyObject followed by the shared fields that functions and frame constructors have in common (_Py_COMMON_FIELDS, with the func_ prefix), then function-specific state:

c
// Include/cpython/funcobject.h
typedef struct {
    PyObject_HEAD
    _Py_COMMON_FIELDS(func_)   /* globals, builtins, name, qualname, code,
                                  defaults, kwdefaults, closure */
    PyObject *func_doc;         /* __doc__, can be anything */
    PyObject *func_dict;        /* __dict__, a dict or NULL */
    PyObject *func_weakreflist; /* weak references */
    PyObject *func_module;      /* __module__ */
    PyObject *func_annotations; /* annotations dict or NULL */
    PyObject *func_annotate;    /* callable producing annotations */
    PyObject *func_typeparams;  /* tuple of type variables or NULL */
    vectorcallfunc vectorcall;   /* _PyFunction_Vectorcall */
    uint32_t func_version;      /* for the specializer guard */
} PyFunctionObject;

The common fields expand to func_globals, func_builtins, func_name, func_qualname, func_code, func_defaults, func_kwdefaults, and func_closure. Note the split between the code object (pure syntax, shared) and the function object (the runtime binding: globals, closure).

That split is worth staring at. Ten copies of the same tiny function body share one PyCodeObject. Each copy still carries its own globals, its own defaults, its own closure.

Creation

Two entry points build a function. PyFunction_NewWithQualName is the public one used by MAKE_FUNCTION; _PyFunction_FromConstructor is used by the frame-constructor path. Both allocate with PyObject_GC_New and fill the fields:

c
// Objects/funcobject.c (PyFunction_NewWithQualName)
PyFunctionObject *op = PyObject_GC_New(PyFunctionObject, &PyFunction_Type);
...
op->func_globals = globals;        // borrowed++ via _Py_INCREF_DICT
op->func_builtins = builtins;      // loaded from globals
op->func_name = name;              // from code->co_name
op->func_qualname = qualname;      // passed or code->co_qualname
op->func_code = (PyObject*)code_obj;
op->func_defaults = NULL;
op->func_kwdefaults = NULL;
op->func_closure = NULL;
op->func_doc = doc;                // consts[0] if CO_HAS_DOCSTRING
op->func_dict = NULL;
op->func_weakreflist = NULL;
op->func_module = module;          // globals['__name__']
op->func_annotations = NULL;
op->func_annotate = NULL;
op->func_typeparams = NULL;
op->vectorcall = _PyFunction_Vectorcall;
op->func_version = FUNC_VERSION_UNSET;

For top-level functions (non-nested, or nested methods) CPython enables deferred reference counting via _PyObject_SetDeferredRefcount, since they are less likely to be in cycles and benefit from prompt reclamation being less critical. Nested closures keep normal refcounting because capturing variables makes prompt deallocation more important.

Gotcha: deferred refcounting means a module-level function can sit around a little longer before its memory comes back. That’s a deliberate trade, not a leak.

Code object

func_code is a PyCodeObject and is always non-NULL. The __code__ setter validates that the replacement is a code object and that its free-var count matches the existing closure length, and warns if the generator/coroutine/async-generator flag changes:

c
// Objects/funcobject.c (func_set_code)
int nfree = ((PyCodeObject *)value)->co_nfreevars;
Py_ssize_t nclosure = (op->func_closure == NULL ? 0 :
                                PyTuple_GET_SIZE(op->func_closure));
if (nclosure != nfree) { ... ValueError ... }
...
op->func_code = Py_NewRef(value);

Changing __code__ (along with defaults, kwdefaults, qualname) fires a watcher event and clears func_version, invalidating specialized call sites.

That last part matters more than it looks. Mutate a function’s code and every cached fast path for calling it gets thrown out. See the versioning section below.

Globals and builtins

func_globals is the module’s globals dict (refcounted specially via _Py_INCREF_DICT/_Py_DECREF_DICT). func_builtins is derived from it with _PyDict_LoadBuiltinsFromGlobals and cached on the function so the eval loop does not re-resolve builtins. __module__ is globals['__name__'] captured at creation.

Caching builtins is a straight-up win: without it, every name lookup that falls through to builtins would have to re-scan the globals dict. You would feel that in hot loops.

Defaults and kwdefaults

Positional defaults live in func_defaults as a tuple (or NULL); keyword-only defaults live in func_kwdefaults as a dict (or NULL). Setters accept None as “clear”, enforce the tuple/dict types, and bump the version:

c
// Objects/funcobject.c (PyFunction_SetDefaults)
PyObject *old_defaults = func->func_defaults;
func->func_defaults = defaults;   // after handle_func_event + stop-the-world

The tuple is aligned to the trailing positional parameters: a function def f(a, b=1, c=2) stores (1, 2).

Worth internalizing: the defaults tuple maps to the end of the parameter list, not the start. f above has three parameters but only two defaults, and they bind to b and c.

Closure (cells)

Free variables are captured through PyCellObject (a box holding one PyObject *ob_ref). func_closure is a tuple of cells, one per co_freevars entry, or NULL when there are none. The invariant (checked in func_new) is:

c
// Objects/funcobject.c
if (code->co_nfreevars != nclosure) ...
for (i) { if (!PyCell_Check(PyTuple_GET_ITEM(closure, i))) ... }
c
// Include/cpython/cellobject.h
typedef struct {
    PyObject_HEAD
    PyObject *ob_ref;   /* content, or NULL when empty */
} PyCellObject;

Mutating a closed-over variable writes through the cell (PyCell_Set), so all sharing functions see the update.

Think of a cell as a labeled box on the side of the frame. Two closures that capture the same variable get the same box, which is exactly why nonlocal works.

Annotations

Annotations are stored lazily. func_annotations may be NULL, a dict, or a tuple of key/value pairs (the compact __annotate__ form used when annotations are deferred, PEP 649/749). func_annotate is a callable that produces the dict on demand:

c
// Objects/funcobject.c (func_get_annotation_dict)
if (op->func_annotations == NULL) {
    if (op->func_annotate == NULL || !PyCallable_Check(op->func_annotate)) {
        Py_RETURN_NONE;
    }
    PyObject *ann_dict = _PyObject_CallOneArg(op->func_annotate, one);
    Py_XSETREF(op->func_annotations, ann_dict);
    return ann_dict;
}
if (PyTuple_CheckExact(op->func_annotations)) {   // expand pairs into dict
    ...
}

__annotations__ getter materializes the dict (creating an empty one if neither field is set), so reads are never NULL from Python’s view.

The real win: you can annotate a function without paying to build the dict until someone actually asks for __annotations__.

qualname

func_qualname is a str (stored separately from func_name so that renaming via __qualname__ does not change __name__). It drives repr (<function %U at %p>) and watcher events (PyFunction_EVENT_MODIFY_QUALNAME). func_set_qualname replaces it under the stop-the-world protocol and fires handle_func_event.

Two fields instead of one — that costs a pointer and buys you a stable __name__ while the qualified name tracks nesting.

vectorcall

PyFunction_Type sets tp_vectorcall_offset = offsetof(PyFunctionObject, vectorcall) and Py_TPFLAGS_HAVE_VECTORCALL, so the eval loop calls functions through the vectorcall protocol rather than tp_call. The function’s vectorcall slot points at _PyFunction_Vectorcall:

c
// Objects/call.c
PyObject *_PyFunction_Vectorcall(PyObject *func, PyObject* const* stack,
                                 size_t nargsf, PyObject *kwnames)
{
    PyFunctionObject *f = (PyFunctionObject *)func;
    Py_ssize_t nargs = PyVectorcall_NARGS(nargsf);
    PyThreadState *tstate = _PyThreadState_GET();
    if (((PyCodeObject *)f->func_code)->co_flags & CO_OPTIMIZED) {
        return _PyEval_Vector(tstate, f, NULL, stack, nargs, kwnames);
    } else {
        return _PyEval_Vector(tstate, f, f->func_globals, stack, nargs, kwnames);
    }
}

For optimized code (no exec/eval of locals at runtime) globals are passed as NULL and the frame uses the fast locals array; otherwise the function’s globals dict is supplied. PyFunction_SetVectorcall lets tools (e.g. profilers/trampolines) swap this slot, which also clears the version.

Notice there’s no tuple of arguments built here. The args already live on the caller’s stack, and vectorcall passes a pointer straight to them. No allocation, no copy.

Versioning and specialization

func_version starts as FUNC_VERSION_UNSET and is set by MAKE_FUNCTION from the code object’s co_version. The Tier 1 specializer inlines it into CALL caches and guards on it; modifying code, defaults, kwdefaults, closure, annotations, or vectorcall clears it to FUNC_VERSION_CLEARED, after which calls to that function are never specialized again. A cache maps version numbers to function/code objects for Tier 2.

This is the part people get wrong. CPython specializes call sites aggressively, and the version field is the invalidation handle. Touch a function’s defaults and you don’t just lose the fast path for that call — you lose it for that function forever.

Dealloc

c
// Objects/funcobject.c (func_dealloc)
_PyObject_GC_UNTRACK(op);
FT_CLEAR_WEAKREFS(self, op->func_weakreflist);
(void)func_clear((PyObject*)op);   // clears globals/builtins/module/defaults/...
_Py_DECREF_CODE((PyCodeObject *)op->func_code);
Py_DECREF(op->func_name);
Py_DECREF(op->func_qualname);
PyObject_GC_Del(op);

func_clear nulls globals/builtins/module/defaults/kwdefaults/doc/dict/ closure/annotations/annotate/typeparams and replaces name/qualname with the immortal empty string (they may be str subclasses with cycles). func_code is deliberately not cleared there but decremented in dealloc, since code is always required to be non-NULL.

Why the empty string? A function’s name can be a str subclass that holds a reference back to the function. Clearing it to the immortal empty string breaks the cycle without losing the invariant that func_name is a str.

Key takeaways

  • A Python function is PyFunctionObject: a GC-tracked object pairing a shared PyCodeObject with a globals dict, builtins, name, qualname, defaults tuple, kwdefaults dict, closure tuple of cells, doc, dict, module, annotations, type params, a vectorcall slot, and a version.
  • Creation binds __name__/builtins from globals; top-level functions use deferred refcounting.
  • Defaults are a trailing-aligned tuple; kwdefaults a dict; closure is a tuple of PyCellObject boxes.
  • Annotations are lazy: NULL/dict or a compact tuple plus an __annotate__ callable.
  • Calls go through _PyFunction_Vectorcall -> _PyEval_Vector, with globals passed only for unoptimized code.
  • func_version enables call specialization and is cleared on mutation.

Further reading

  • Objects/funcobject.c – all of the above.
  • Include/cpython/funcobject.h – PyFunctionObject and common fields.
  • Objects/call.c – _PyFunction_Vectorcall.
  • Include/cpython/cellobject.h – PyCellObject.

PythonCpythonInternalsFunction