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
defstatement hands you aPyFunctionObject: shared bytecode on one side, runtime binding (globals, defaults, closure) on the other. Calls skiptp_callentirely and go straight through_PyFunction_Vectorcallinto_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:
// 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:
// 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:
// 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:
// 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:
// Objects/funcobject.c
if (code->co_nfreevars != nclosure) ...
for (i) { if (!PyCell_Check(PyTuple_GET_ITEM(closure, i))) ... }// 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:
// 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:
// 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
// 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 sharedPyCodeObjectwith 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
PyCellObjectboxes. - 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_versionenables call specialization and is cleared on mutation.
Further reading
Objects/funcobject.c– all of the above.Include/cpython/funcobject.h–PyFunctionObjectand common fields.Objects/call.c–_PyFunction_Vectorcall.Include/cpython/cellobject.h–PyCellObject.