How CPython Implements Descriptors (property, classmethod, staticmethod, and generic descriptors) in C

Descriptors in CPython 3.15: the PyDescrObject base, tp_descr_get/tp_descr_set slots, data vs non-data descriptors, member_descriptor and getset_descriptor, propertyobject, classmethod and staticmethod, and how attribute resolution invokes them, traced through descrobject.c and funcobject.c.

Descriptors are the mechanism behind property, classmethod, staticmethod, slots, and most of the attributes you read off classes. In CPython they are not a separate object hierarchy so much as a pair of slots on a type object – tp_descr_get and tp_descr_set – plus a small family of C structs that implement them. This article traces that implementation through the 3.15 source.

The descriptor protocol: tp_descr_get / tp_descr_set

Every type object carries two optional function pointers:

c
// Include/cpython/object.h (in PyTypeObject)
descrgetfunc tp_descr_get;
descrsetfunc tp_descr_set;

with signatures

c
PyObject *(*tp_descr_get)(PyObject *self, PyObject *obj, PyObject *type);
int       (*tp_descr_set)(PyObject *self, PyObject *obj, PyObject *value);

When an attribute lookup finds an object in a class (or its bases) whose type has tp_descr_get, CPython calls it instead of returning the object raw. self is the descriptor found, obj is the instance (or NULL for class-level access), and type is the class. If tp_descr_set is non-NULL the object is a data descriptor and wins over instance __dict__ entries; otherwise it is a non-data descriptor and loses to them. That single distinction is PyDescr_IsData:

c
// Objects/descrobject.c
int
PyDescr_IsData(PyObject *ob)
{
    return Py_TYPE(ob)->tp_descr_set != NULL;
}

So the protocol is just: “if the found attribute’s type has these slots, call them.” Everything below is a particular implementation of them.

The common base: PyDescrObject

All the built-in descriptors in descrobject.c share a head:

c
// Include/cpython/descrobject.h
typedef struct {
    PyObject_HEAD
    PyTypeObject *d_type;     /* type the descriptor belongs to */
    PyObject *d_name;         /* attribute name */
    PyObject *d_qualname;     /* qualified name */
} PyDescrObject;

d_type is used by descr_check to verify the object an instance descriptor is applied to is really an instance of that type:

c
// Objects/descrobject.c
static int
descr_check(PyDescrObject *descr, PyObject *obj)
{
    if (!PyObject_TypeCheck(obj, descr->d_type)) {
        PyErr_Format(PyExc_TypeError,
                     "descriptor '%V' for '%.100s' objects "
                     "doesn't apply to a '%.100s' object", ...);
        return -1;
    }
    return 0;
}

d_name/d_qualname drive __qualname__ and the error messages. Each concrete descriptor embeds this as PyDescr_COMMON and adds its payload.

member_descriptor: PyMemberDescrObject

A member_descriptor is what __slots__ and PyMemberDef produce. It wraps a PyMemberDef pointing at a C-level field offset:

c
// Include/cpython/descrobject.h
typedef struct {
    PyDescr_COMMON;
    PyMemberDef *d_member;
} PyMemberDescrObject;

Its get/set are thin wrappers over PyMember_GetOne/PyMember_SetOne, which read/write the raw C field at the given offset:

c
// Objects/descrobject.c
static PyObject *
member_get(PyObject *self, PyObject *obj, PyObject *type)
{
    PyMemberDescrObject *descr = (PyMemberDescrObject *)self;
    if (obj == NULL) {
        return Py_NewRef(descr);          // class access returns the descriptor
    }
    if (descr_check((PyDescrObject *)descr, obj) < 0) {
        return NULL;
    }
    ...
    return PyMember_GetOne((char *)obj, descr->d_member);
}

static int
member_set(PyObject *self, PyObject *obj, PyObject *value)
{
    PyMemberDescrObject *descr = (PyMemberDescrObject *)self;
    if (descr_setcheck((PyDescrObject *)descr, obj, value) < 0) {
        return -1;
    }
    return PyMember_SetOne((char *)obj, descr->d_member, value);
}

Because member_set is non-NULL, every member_descriptor is a data descriptor, so it shadows instance __dict__.

getset_descriptor: PyGetSetDescrObject

getset_descriptor is the C equivalent of property for types defined in C: it wraps a PyGetSetDef holding get/set function pointers and a closure:

c
// Include/cpython/descrobject.h
typedef struct {
    PyDescr_COMMON;
    PyGetSetDef *d_getset;
} PyGetSetDescrObject;
c
// Objects/descrobject.c
static PyObject *
getset_get(PyObject *self, PyObject *obj, PyObject *type)
{
    PyGetSetDescrObject *descr = (PyGetSetDescrObject *)self;
    if (obj == NULL) {
        return Py_NewRef(descr);
    }
    if (descr_check((PyDescrObject *)descr, obj) < 0) {
        return NULL;
    }
    if (descr->d_getset->get != NULL)
        return descr_get_trampoline_call(
            descr->d_getset->get, obj, descr->d_getset->closure);
    PyErr_Format(PyExc_AttributeError, "attribute '%V' ... is not readable", ...);
    return NULL;
}

static int
getset_set(PyObject *self, PyObject *obj, PyObject *value)
{
    PyGetSetDescrObject *descr = (PyGetSetDescrObject *)self;
    if (descr_setcheck((PyDescrObject *)descr, obj, value) < 0) {
        return -1;
    }
    if (descr->d_getset->set != NULL) {
        return descr_set_trampoline_call(
            descr->d_getset->set, obj, value, descr->d_getset->closure);
    }
    PyErr_Format(PyExc_AttributeError, "attribute '%V' ... is not writable", ...);
    return -1;
}

get/set may be NULL, in which case the attribute is read-only or write-only. Data-descriptor status depends on whether set is provided at type creation – tp_descr_set is wired to getset_set only when the PyGetSetDef has a setter.

property: propertyobject

property is the Python-level descriptor, defined in the same file. Its struct lives in the internal header:

c
// Include/internal/pycore_descrobject.h
typedef struct {
    PyObject_HEAD
    PyObject *prop_get;
    PyObject *prop_set;
    PyObject *prop_del;
    PyObject *prop_doc;
    PyObject *prop_name;
    int getter_doc;
} propertyobject;

It implements both slots, so it is a data descriptor:

c
// Objects/descrobject.c
static PyObject *
property_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
    if (obj == NULL || obj == Py_None) {
        return Py_NewRef(self);          // class access returns the property itself
    }
    propertyobject *gs = (propertyobject *)self;
    if (gs->prop_get == NULL) {
        ... raise AttributeError("property ... has no getter") ...
        return NULL;
    }
    return PyObject_CallOneArg(gs->prop_get, obj);
}

static int
property_descr_set(PyObject *self, PyObject *obj, PyObject *value)
{
    propertyobject *gs = (propertyobject *)self;
    PyObject *func = (value == NULL) ? gs->prop_del : gs->prop_set;
    ...
    if (value == NULL)
        res = PyObject_CallOneArg(func, obj);
    else {
        PyObject *args[] = { obj, value };
        res = PyObject_Vectorcall(func, args, 2, NULL);
    }
    ...
}

property.__init__ (clinic-generated property_init_impl) stores the callables, resolves __doc__ from the getter when none is given, and records getter_doc so the doc assignment can be retried later. property_copy is what .getter/.setter/.deleter use to build a new property with one slot replaced – that is how the decorator chaining works.

Because property_descr_set is always wired up, a property is a data descriptor even when it has no setter: writes then raise AttributeError instead of falling through to __dict__.

classmethod: classmethod

Defined in Objects/funcobject.c:

c
typedef struct {
    PyObject_HEAD
    PyObject *cm_callable;
    PyObject *cm_dict;
} classmethod;

Its descriptor binds the wrapped callable to the class, not the instance:

c
// Objects/funcobject.c
static PyObject *
cm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
    classmethod *cm = (classmethod *)self;
    if (type == NULL)
        type = (PyObject *)(Py_TYPE(obj));
    return PyMethod_New(cm->cm_callable, type);
}

Even instance.method binds to type(instance), and class access binds to the class passed in. cm_callable is stored under __func__/__wrapped__, and functools_wraps copies metadata during construction.

staticmethod: staticmethod

c
typedef struct {
    PyObject_HEAD
    PyObject *sm_callable;
    PyObject *sm_dict;
} staticmethod;
c
static PyObject *
sm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
    staticmethod *sm = (staticmethod *)self;
    return Py_NewRef(sm->sm_callable);
}

A staticmethod never binds: it just returns the wrapped callable. It has no tp_descr_set, so it is a non-data descriptor and, as an optimization, the attribute machinery in typeobject.c short-circuits it entirely:

c
// Objects/typeobject.c (attribute resolution)
if (Py_TYPE(attr_obj) == &PyStaticMethod_Type) {
    PyObject *callable = _PyStaticMethod_GetFunc(attr_obj);
    if (callable) {
        result_ref.ref = PyStackRef_FromPyObjectNew(callable);
        goto done;
    }
}

Generic descriptors and the rest of descrobject.c

The same file also hosts the C-level descriptors used by built-in types:

  • PyMethodDescrObject (method_descriptor) – wraps a PyMethodDef; method_get binds it into a C function/method on instance access, or returns the descriptor on class access.
  • PyWrapperDescrObject (slot_wrapper) – wraps a wrapperbase slot such as __init__ or __repr__; wrapperdescr_get returns a PyWrapper_New bound wrapper.

Both are non-data descriptors (tp_descr_set == 0), so user code can override them in a subclass __dict__.

Python-level descriptors

A plain Python class can also be a descriptor by defining __get__, __set__, or __delete__. When such a class is stored in another class’s __dict__, typeobject.c does not call those methods directly; instead the machinery in Objects/typeobject.c synthesizes the slot functions slot_tp_descr_get/slot_tp_descr_set during PyType_Ready, which look up __get__/__set__/__delete__ on the descriptor’s type and invoke them. That is how a user-written class MyDescr with a __get__ method ends up with a non-NULL tp_descr_get even though its C type started with none. The same mechanism fills in tp_descr_set when __set__ or __delete__ is present, making the descriptor a data descriptor.

Py_TPFLAGS_HAVE_GC and friends are unrelated; descriptor-ness is purely a function of those two slots. PyType_Ready also propagates tp_descr_get/tp_descr_set from base classes via COPYSLOT, and inherits Py_TPFLAGS_METHOD_DESCRIPTOR when a type’s tp_descr_get is the same as its base’s, which lets the interpreter skip some calls for plain methods.

How attribute resolution drives all of this

The central routine (in Objects/typeobject.c) looks roughly like:

  1. Look in the metatype; if a data descriptor is found there, call its tp_descr_get and return.
  2. Otherwise look in the type and its bases.
  3. If a data descriptor is found, call tp_descr_get (instance ignored for class-level access).
  4. If a plain value is found, return it, unless an instance __dict__ entry exists and the found descriptor is non-data.
  5. If nothing is found, fall back to __getattr__/__getattribute__.

That ordering is why property and slots (data descriptors) beat instance attributes, while functions, classmethod, staticmethod, and method descriptors (non-data) can be shadowed by instance attributes.

Key takeaways

  • Descriptors are just tp_descr_get/tp_descr_set slots; data vs non-data is determined by whether tp_descr_set is non-NULL.
  • PyDescrObject (d_type, d_name, d_qualname) is the common head for member_descriptor, getset_descriptor, method/slot wrappers.
  • member_descriptor reads/writes a C field via PyMember_GetOne/ SetOne; getset_descriptor calls a PyGetSetDef’s get/set.
  • property stores prop_get/set/del/doc/name and is always a data descriptor.
  • classmethod binds to the class (PyMethod_New(cm_callable, type)), staticmethod returns the callable unchanged, and the type machinery short-circuits the latter.
  • Attribute resolution calls tp_descr_get with (descriptor, instance, class) and orders data descriptors before instance __dict__.

Further reading

  • Objects/descrobject.c – PyDescrObject, member_get/set, getset_get/set, property_descr_get/set, property_init_impl.
  • Include/cpython/descrobject.h – PyDescrObject, PyMemberDescrObject, PyGetSetDescrObject, PyWrapperDescrObject.
  • Include/internal/pycore_descrobject.h – propertyobject.
  • Objects/funcobject.c – classmethod/staticmethod structs, cm_descr_get, sm_descr_get.
  • Objects/typeobject.c – attribute resolution and the staticmethod short-circuit.
  • Lib/test/test_descr.py, Lib/test/test_property.py – reference behavior and edge cases.

PythonCpythonInternalsDescriptorProperty