Skip to content

43. Descriptors

__get__, __set__, __delete__ protocol, data vs. non-data descriptors, and property/classmethod/staticmethod internals.

A descriptor is an object that controls attribute access on another object. Descriptors are one of the main mechanisms behind Python’s object model. They explain how methods bind to instances, how property works, how staticmethod and classmethod work, how slots work, and how many CPython-level type operations connect to Python-level syntax.

At the language level, a descriptor is any object that defines one or more of these methods:

__get__(self, obj, objtype=None)
__set__(self, obj, value)
__delete__(self, obj)

An object with __get__ is a descriptor.

An object with __set__ or __delete__ is a data descriptor.

The distinction between non-data descriptors and data descriptors controls lookup precedence.

43.1 Why Descriptors Exist

Python attribute access looks simple:

obj.name

But this expression does not mean “read a field named name from memory.”

It means:

ask the object's type how attribute lookup works
search descriptors and dictionaries in a defined order
possibly call descriptor methods
return the resulting object

Descriptors make attribute access programmable. They allow one object to mediate access to another object’s attribute.

Examples:

obj.method
obj.property_name
Class.class_method
Class.static_method
obj.slot_name

All of these involve descriptor behavior.

43.2 Basic Descriptor Protocol

A descriptor defines __get__.

class Descriptor:
    def __get__(self, obj, objtype=None):
        return "computed value"

Use it as a class attribute:

class Example:
    value = Descriptor()

e = Example()

print(e.value)

Output:

computed value

The descriptor object is stored on the class:

print(Example.__dict__["value"])

But attribute access through the instance calls Descriptor.__get__.

43.3 Descriptor Arguments

The descriptor method receives:

ArgumentMeaning
selfThe descriptor object
objThe instance being accessed, or None for class access
objtypeThe owner class

Example:

class Descriptor:
    def __get__(self, obj, objtype=None):
        print("obj:", obj)
        print("objtype:", objtype)
        return 42

class Example:
    value = Descriptor()

e = Example()

print(e.value)
print(Example.value)

For instance access:

obj: <__main__.Example object at ...>
objtype: <class '__main__.Example'>

For class access:

obj: None
objtype: <class '__main__.Example'>

A descriptor can use this difference to return different values for instance and class access.

43.4 Non-Data Descriptors

A non-data descriptor defines __get__, but not __set__ or __delete__.

class NonDataDescriptor:
    def __get__(self, obj, objtype=None):
        return "descriptor value"

Example:

class Example:
    value = NonDataDescriptor()

e = Example()

print(e.value)

Output:

descriptor value

But because it is a non-data descriptor, an instance dictionary entry can override it:

e.__dict__["value"] = "instance value"

print(e.value)

Output:

instance value

This precedence is intentional. It is how normal methods can be shadowed by instance attributes.

43.5 Data Descriptors

A data descriptor defines __set__ or __delete__.

class DataDescriptor:
    def __get__(self, obj, objtype=None):
        return "descriptor value"

    def __set__(self, obj, value):
        print("setting", value)

Example:

class Example:
    value = DataDescriptor()

e = Example()
e.__dict__["value"] = "instance value"

print(e.value)

Output:

descriptor value

The data descriptor wins over the instance dictionary.

This rule is critical for property.

43.6 Lookup Precedence

For normal instance attribute access:

obj.name

CPython’s object lookup roughly follows this order:

1. Look for name on the type or base types.
2. If found and it is a data descriptor, call its __get__.
3. Look in the instance dictionary.
4. If found on the type and it is a non-data descriptor, call its __get__.
5. If found on the type as a normal attribute, return it.
6. If not found, call __getattr__ if defined.
7. Otherwise raise AttributeError.

This order explains why some class attributes can be shadowed by instance attributes and others cannot.

43.7 Descriptor Precedence Example

class NonData:
    def __get__(self, obj, objtype=None):
        return "non-data descriptor"

class Data:
    def __get__(self, obj, objtype=None):
        return "data descriptor"

    def __set__(self, obj, value):
        raise AttributeError("read-only")

class Example:
    a = NonData()
    b = Data()

e = Example()
e.__dict__["a"] = "instance a"
e.__dict__["b"] = "instance b"

print(e.a)
print(e.b)

Output:

instance a
data descriptor

a is shadowed because it is a non-data descriptor.

b is not shadowed because it is a data descriptor.

43.8 Functions Are Descriptors

Functions stored on classes are descriptors.

class Example:
    def method(self):
        return 42

e = Example()

print(Example.__dict__["method"])
print(e.method)

The object stored in the class dictionary is a function object.

The object returned by instance access is a bound method.

function object stored on class
    ↓ __get__(instance, class)
bound method object

This is why:

e.method()

passes e as the first argument.

43.9 Method Binding

A function’s descriptor behavior can be modeled like this:

class FunctionLike:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return BoundMethod(self, obj)

The bound method stores:

function
instance

Calling the bound method:

e.method(1, 2)

is roughly equivalent to:

Example.method(e, 1, 2)

This is not special syntax in the parser. It is ordinary attribute lookup plus descriptor binding plus call.

43.10 Class Access to Methods

When accessed through the class, a function descriptor receives obj=None.

class Example:
    def method(self):
        return 42

print(Example.method)

The result is the original function-like object, not a bound method to an instance.

So this works:

e = Example()

print(Example.method(e))

The instance is passed explicitly.

43.11 Bound Method Objects

A bound method object exposes useful attributes:

class Example:
    def method(self):
        return 42

e = Example()
m = e.method

print(m.__self__)
print(m.__func__)

__self__ is the bound instance.

__func__ is the underlying function.

Conceptually:

bound_method.__self__ -> e
bound_method.__func__ -> Example.__dict__["method"]

Calling:

m()

calls:

m.__func__(m.__self__)

43.12 property

property is a data descriptor.

class User:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

Access:

u = User("Ada")
print(u.name)

calls the property’s getter.

A rough model:

class property:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

Because property defines __set__, it is a data descriptor. It wins over the instance dictionary.

43.13 Read-Only Properties

A read-only property still acts like a data descriptor.

class Example:
    @property
    def value(self):
        return 42

e = Example()
e.__dict__["value"] = 100

print(e.value)

Output:

42

Even though the property has no setter, the property type has __set__ behavior that raises an error. That makes it a data descriptor.

43.14 Writable Properties

A writable property adds a setter.

class User:
    def __init__(self):
        self._name = ""

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not value:
            raise ValueError("empty name")
        self._name = value

Usage:

u = User()
u.name = "Ada"
print(u.name)

Assignment:

u.name = "Ada"

calls the descriptor’s __set__.

It does not simply write u.__dict__["name"].

43.15 staticmethod

staticmethod is a descriptor that suppresses method binding.

class Math:
    @staticmethod
    def add(a, b):
        return a + b

print(Math.add(2, 3))
print(Math().add(2, 3))

In both cases, no instance is inserted as the first argument.

A rough model:

class staticmethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        return self.func

staticmethod stores a function and returns it unchanged.

43.16 classmethod

classmethod is a descriptor that binds the class instead of the instance.

class User:
    @classmethod
    def create(cls):
        return cls()

u = User.create()

A rough model:

class classmethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        if objtype is None:
            objtype = type(obj)
        return BoundMethod(self.func, objtype)

Calling:

User.create()

is roughly equivalent to:

User.__dict__["create"].__get__(None, User)()

which calls:

original_function(User)

43.17 __slots__ and Descriptors

__slots__ uses descriptors to manage fixed-layout instance attributes.

Example:

class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

The class dictionary contains slot descriptors:

print(Point.__dict__["x"])
print(Point.__dict__["y"])

These descriptors know how to read and write values from fixed offsets in the instance layout.

With slots, normal instances may not have __dict__ unless explicitly requested.

p = Point(1, 2)

print(hasattr(p, "__dict__"))

Output:

False

43.18 Member Descriptors and Getset Descriptors

CPython exposes some C-level descriptors as objects.

Common examples:

member_descriptor
getset_descriptor
wrapper_descriptor
method_descriptor

You can see them in class dictionaries:

class Point:
    __slots__ = ("x",)

print(type(Point.__dict__["x"]))

For built-in types:

print(type(int.__dict__["real"]))
print(type(str.__dict__["upper"]))
print(type(object.__dict__["__str__"]))

These are CPython-level descriptor objects wrapping C-level behavior.

43.19 Descriptor and Attribute Assignment

For assignment:

obj.name = value

CPython does not always write into obj.__dict__.

If the type has a data descriptor for name, assignment calls the descriptor’s __set__.

Example:

class Descriptor:
    def __set__(self, obj, value):
        print("set", value)

class Example:
    x = Descriptor()

e = Example()
e.x = 10

Output:

set 10

No normal instance dictionary write is required.

43.20 Descriptor and Attribute Deletion

For deletion:

del obj.name

If a data descriptor defines __delete__, CPython calls it.

class Descriptor:
    def __delete__(self, obj):
        print("delete")

class Example:
    x = Descriptor()

e = Example()
del e.x

Output:

delete

Otherwise, deletion may remove an entry from the instance dictionary or raise AttributeError.

43.21 __set_name__

Descriptors can define __set_name__.

class Field:
    def __set_name__(self, owner, name):
        self.owner = owner
        self.name = name

class User:
    id = Field()
    name = Field()

During class creation, Python calls:

User.__dict__["id"].__set_name__(User, "id")
User.__dict__["name"].__set_name__(User, "name")

This lets descriptors learn the attribute name they were assigned to.

Without __set_name__, descriptors often require redundant configuration:

id = Field("id")
name = Field("name")

43.22 Practical Validating Descriptor

class PositiveInt:
    def __set_name__(self, owner, name):
        self.name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if not isinstance(value, int):
            raise TypeError("expected int")
        if value <= 0:
            raise ValueError("expected positive integer")
        setattr(obj, self.name, value)

Usage:

class User:
    age = PositiveInt()

    def __init__(self, age):
        self.age = age

u = User(30)
print(u.age)

The assignment:

self.age = age

calls PositiveInt.__set__.

The access:

u.age

calls PositiveInt.__get__.

43.23 Descriptor Storage Choices

Descriptors usually store per-instance values somewhere else.

Common choices:

StorageExampleTradeoff
Instance dictionaryobj.__dict__[name]Simple, requires __dict__
Private attributeobj._nameSimple, can collide
Weak key dictionaryWeakKeyDictionary[obj]Works without touching instance dict, higher overhead
Slot offsetCPython internalFast, fixed layout
External storeORM/session/state tableUseful for frameworks

A descriptor object is usually shared by the class, so storing instance-specific data directly on the descriptor is usually wrong.

Bad:

class BadField:
    def __set__(self, obj, value):
        self.value = value

All instances share one descriptor object.

Better:

class Field:
    def __set_name__(self, owner, name):
        self.name = "_" + name

    def __set__(self, obj, value):
        setattr(obj, self.name, value)

43.24 Descriptor Sharing

A descriptor is stored on the class.

class Field:
    pass

class User:
    name = Field()

There is one Field object for User.name.

All User instances access the same descriptor object.

print(User.__dict__["name"])

This is why descriptor state must be designed carefully.

Descriptor-level state is good for:

field name
validation rule
default configuration
metadata
owner class

Instance-level state belongs on the instance or in an external per-instance store.

43.25 Descriptors and Inheritance

Descriptors are inherited like other class attributes.

class Field:
    def __get__(self, obj, objtype=None):
        return "value"

class Base:
    x = Field()

class Child(Base):
    pass

print(Child().x)

Output:

value

Lookup searches the method resolution order. If a descriptor is found in a base class, it participates in attribute access for child instances.

A subclass can override the descriptor by defining the same name.

class Child(Base):
    x = 100

Now:

print(Child().x)

returns:

100

unless other lookup rules apply.

43.26 Descriptors and super

super() also uses descriptor binding.

Example:

class Base:
    def method(self):
        return "base"

class Child(Base):
    def method(self):
        return super().method()

The expression:

super().method

finds method on the next class in the MRO and binds it to the original instance.

That binding still uses descriptor logic.

43.27 Descriptors and __getattribute__

All normal attribute access goes through __getattribute__.

obj.name

calls roughly:

type(obj).__getattribute__(obj, "name")

The default implementation, object.__getattribute__, implements descriptor lookup.

If a class overrides __getattribute__, it can change or bypass descriptor behavior.

Example:

class Example:
    @property
    def x(self):
        return 42

    def __getattribute__(self, name):
        return "intercepted"

e = Example()
print(e.x)

Output:

intercepted

The property is never reached because __getattribute__ intercepts everything.

43.28 Descriptors and __getattr__

__getattr__ is only called after normal lookup fails.

class Example:
    def __getattr__(self, name):
        return "missing"

e = Example()
print(e.anything)

Output:

missing

If a descriptor exists and returns a value, __getattr__ is not called.

Lookup order:

__getattribute__ runs first
descriptor rules happen inside normal __getattribute__
__getattr__ handles missing names only

43.29 Descriptors and Metaclasses

Class attribute access uses descriptor logic too, but the object being searched is a class object.

class Meta(type):
    @property
    def label(cls):
        return cls.__name__.lower()

class User(metaclass=Meta):
    pass

print(User.label)

Here, label is a descriptor on the metaclass. Accessing User.label invokes descriptor lookup on type(User).

This is why metaclasses can define computed class attributes.

43.30 Descriptor Lookup on Classes

For:

Class.attr

lookup is handled by type.__getattribute__.

The search happens on the class object and its metaclass.

If an attribute in the metaclass is a descriptor, it may bind with the class as the object.

This is how classmethod, metaclass properties, and many built-in type operations work.

Class lookup and instance lookup are related but not identical. Instance lookup searches the instance type’s MRO. Class lookup searches through metaclass machinery.

43.31 Descriptors in ORMs

Descriptors are common in ORMs.

Example shape:

class Column:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj._row[self.name]

    def __set__(self, obj, value):
        obj._row[self.name] = value

Usage:

class User:
    id = Column()
    name = Column()

    def __init__(self, row):
        self._row = row

Then:

u = User({"id": 1, "name": "Ada"})
print(u.name)
u.name = "Grace"

The descriptor maps attribute access to row storage.

43.32 Descriptors in Validation Libraries

Validation frameworks use descriptors to enforce constraints.

class String:
    def __set_name__(self, owner, name):
        self.storage = "_" + name

    def __set__(self, obj, value):
        if not isinstance(value, str):
            raise TypeError("expected str")
        setattr(obj, self.storage, value)

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage)

Usage:

class User:
    name = String()

    def __init__(self, name):
        self.name = name

The class definition declares the field. The descriptor enforces the rule.

43.33 Descriptors in Cached Computation

A cached property is a non-data descriptor.

class cached_property:
    def __init__(self, func):
        self.func = func
        self.name = func.__name__

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self

        value = self.func(obj)
        obj.__dict__[self.name] = value
        return value

Usage:

class Data:
    @cached_property
    def total(self):
        print("computing")
        return 100

d = Data()
print(d.total)
print(d.total)

First access calls the descriptor and stores the value in the instance dictionary.

Second access returns the instance dictionary value because the descriptor is non-data.

This relies directly on descriptor precedence.

43.34 Data vs Non-Data Design Rule

Use a data descriptor when the descriptor must control writes or must not be overridden by instance attributes.

Use a non-data descriptor when instance-level caching or shadowing is desired.

Descriptor kindDefinesInstance dict can override?Common example
Non-data descriptor__get__ onlyYesfunctions, cached property
Data descriptor__set__ or __delete__Noproperty, slots, validating fields

This one distinction explains many attribute access behaviors.

43.35 CPython Internal Slots for Descriptors

At the C level, descriptor behavior is represented through type slots.

The relevant operations correspond to:

tp_descr_get
tp_descr_set

A type that implements tp_descr_get can act like it has __get__.

A type that implements tp_descr_set can act like it has __set__ or __delete__.

Built-in descriptors are often C objects whose types provide these slots.

This is how built-in methods, slot descriptors, getset descriptors, and wrapper descriptors integrate with normal Python lookup.

43.36 Attribute Lookup in CPython Terms

A simplified CPython-level lookup for obj.name:

1. type = Py_TYPE(obj)
2. descr = lookup name in type MRO
3. if descr has tp_descr_get and tp_descr_set:
       return descr.__get__(obj, type)
4. if obj has dict and name in dict:
       return dict[name]
5. if descr has tp_descr_get:
       return descr.__get__(obj, type)
6. if descr exists:
       return descr
7. call fallback or raise AttributeError

This is the operational heart of descriptors.

43.37 Descriptor Errors

Descriptors should raise AttributeError for missing attributes when they want normal attribute fallback behavior.

Example:

class Maybe:
    def __get__(self, obj, objtype=None):
        raise AttributeError("not available")

A descriptor that raises AttributeError may interact with getattr, hasattr, and fallback mechanisms.

Raise precise exceptions. Do not hide unrelated errors as AttributeError unless the attribute is genuinely unavailable.

43.38 Descriptor Introspection

To inspect a descriptor, access it through the class dictionary.

class Example:
    @property
    def value(self):
        return 42

print(Example.__dict__["value"])

Accessing through the class may call __get__:

print(Example.value)

For property, class access returns the property object. But other descriptors may return computed values.

The safest way to retrieve the raw descriptor is usually:

vars(Example)["value"]

or:

Example.__dict__["value"]

43.39 Common Descriptor Bugs

BugCauseFix
All instances share one valueStored instance state on descriptorStore state on instance or external per-instance map
Property ignored by custom classOverrode __getattribute__ incorrectlyDelegate to object.__getattribute__
Cached property does not cacheDescriptor is data descriptorMake it non-data or write custom precedence logic
Instance attribute cannot override fieldDescriptor defines __set__Use non-data descriptor if override is desired
Descriptor lacks field nameDid not implement __set_name__Add __set_name__ or pass name explicitly
Class access breaks__get__ does not handle obj is NoneReturn descriptor or class-level object

43.40 Key Points

Descriptors are objects that define __get__, __set__, or __delete__.

A non-data descriptor defines __get__ only.

A data descriptor defines __set__ or __delete__.

Data descriptors take precedence over instance dictionaries.

Non-data descriptors can be shadowed by instance dictionaries.

Functions are descriptors, which is why methods bind to instances.

property, staticmethod, classmethod, slots, built-in methods, and many CPython internals use descriptors.

Descriptors are implemented in CPython through descriptor slots on type objects.

Understanding descriptors is required for understanding methods, properties, slots, metaclasses, and attribute lookup.