Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~29 min· topic 5 of 9

Topic 13.5

Descriptors

In one line

A descriptor is an object stored on a class that controls what happens when an attribute is read, set or deleted, through __get__, __set__ and __set_name__. property, methods, classmethod, staticmethod and cached_property are all descriptors.

Think of it like this

A hotel safe-deposit counter. You do not open the box yourself. You ask the clerk, who checks your room number, fetches the box and hands you the contents; for putting things in, the clerk checks the rules first (no flammable items). A descriptor is the clerk standing in front of an attribute: reading, writing and deleting go through the clerk's methods instead of touching a plain stored value.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Descriptor
An object with __get__ (and maybe __set__ or __delete__) stored on a class, controlling attribute access.
Data descriptor
A descriptor that defines __set__ or __delete__. It wins over the instance's own __dict__.
Non-data descriptor
A descriptor with only __get__. An instance attribute of the same name wins over it.
`__get__`
Called when the attribute is read. Receives the instance (or None when read on the class) and the type.
`__set__`
Called when the attribute is assigned on an instance.
`__set_name__`
Called once when the class is created, telling the descriptor its attribute name.
Validation
Checking that a value is acceptable and rejecting it with an error otherwise.
Lazy attribute
A value computed the first time it is needed and then kept.

Step by step

01A validating descriptor

Positive is a descriptor class. price = Positive() in Item stores one Positive object on the class. When self.price = price runs in __init__, Python finds the data descriptor on the type and calls its __set__, which checks the value and then saves it under a private name on the instance.

Because __set__ runs for every assignment, you cannot put a bad value in, whether through __init__ or later with a.price = -5.

main.pywhole filepython
class Positive:
    def __set_name__(self, owner, name):
        self.private = "_" + name

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

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.private[1:]} must be positive, got {value}")
        setattr(obj, self.private, value)
A validating descriptordiagram
Rendering diagram…

02Where the data lives

The descriptor object is shared by every Item. The values are not: they live on each instance as _price and _quantity. vars(a) shows those two names, and reading Item.price on the class returns the descriptor itself.

03Rebuild property

my_property stores the getter function and calls it from __get__. It also defines __set__, which makes it a data descriptor. Without a setter, __set__ just raises. Real property also supports setter, deleter and a docstring.

main.pywhole filepython
class my_property:
    def __init__(self, fget):
        self.fget = fget

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

    def __set__(self, obj, value):
        raise AttributeError("can't set attribute")

04Rebuild cached_property

my_cached_property has only __get__, so it is a non-data descriptor. The first read computes the value and stores it in obj.__dict__ under the same name. From then on, obj.area finds the value in the instance dict before reaching the descriptor, so the function is never called again. The cache is just the instance dict.

To reset the cache, del obj.area.

05Functions are descriptors

def hello(self): ... gives a function object with a __get__ and no __set__. Calling hello.__get__(c, Circle) does exactly what c.hello does: it returns a bound method. This one mechanism is how self gets filled in.

classmethod.__get__ returns a method bound to the class instead, and staticmethod.__get__ returns the plain function.

06Data versus non-data in the lookup

Put a value with the same name in the instance's __dict__. A data descriptor ignores it and still wins. A non-data descriptor loses to it. That is why a method can be replaced on one instance (obj.method = something) but a property cannot.

Data versus non-data in the lookupdiagram
Rendering diagram…

Try it yourself

  1. 1

    Add a maximum

    In the first example make Positive accept an optional maximum in __init__ (price = Positive(maximum=1000)) and reject larger values. Check that quantity = Positive() still works with no limit.

  2. 2

    Reset the cache

    In the second example run del c.area, then read c.area again and print Circle.calls. The cache lived in the instance dict, so deleting the entry made the descriptor compute again.

  3. 3

    Share the value by mistake

    Change Positive.__set__ to store the value on the descriptor (self.value = value) and __get__ to return self.value. Create a and b with different prices and print both. Both now show the same number. Explain why before you read on: one descriptor object serves every instance.

Code & diagrams

A validating descriptor with __set_name__ New tab
Sign in to run this example in your browser.

Expected output

10 3 99
['_price', '_quantity']
Positive
ValueError: price must be positive, got -5
10
property and cached_property rebuilt from scratch New tab

Real code should use the built-in `property` and `functools.cached_property`. This shows how they work underneath.

Sign in to run this example in your browser.

Expected output

10
AttributeError: can't set attribute
before: False
75 75 75
area computed 1 time(s)
after: True
function True False
hi from Circle
Data versus non-data descriptors New tab
Sign in to run this example in your browser.

Expected output

from data descriptor
instance n

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Assign to a property with no setter

Assign to a read-only property.

main.pywhole filepython
class Circle:
    def __init__(self, r):
        self.r = r

    @property
    def area(self):
        return 3 * self.r ** 2


c = Circle(2)
c.area = 50
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 11, in <module>
c.area = 50
^^^^^^
AttributeError: property 'area' of 'Circle' object has no setter

Myth vs fact

Myth

Descriptors are an obscure feature you never use.

Fact

Every method call, every @property, @classmethod, @staticmethod and @cached_property goes through the descriptor protocol.

Myth

A descriptor can keep its value in self.value.

Fact

The descriptor object is shared by all instances of the class. Per-instance data must live on the instance.

Myth

Descriptors work as instance attributes.

Fact

They only work when stored on a class. A descriptor assigned to self.x in __init__ is just an ordinary object, and __get__ is never called.

Myth

cached_property is a special language feature.

Fact

It is a small non-data descriptor that writes its answer into the instance __dict__. Any class could implement it, as the example does.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    functools.cached_property needs the instance to have a __dict__, so it does not work on classes that use __slots__ without a __dict__ slot. Since Python 3.12 it no longer takes a lock around the computation, which removed a source of contention; do not assume thread-safe once-only evaluation.

  • ▸

    __set_name__ also works on objects that are not descriptors, which makes it a general hook for 'learn your name when assigned to a class', used by enums, dataclasses.field and ORMs. It runs after the class body but before __init_subclass__ of the parent (Topic 13.6).

  • ▸

    __slots__ is itself built on descriptors: each slot name becomes a member descriptor on the class (a data descriptor that reads and writes a fixed offset in the object), which is why slotted instances have no __dict__ and why a class attribute with the same name as a slot is rejected. __set_name__ is called only while the class is being created: a descriptor attached later (Cls.attr = Positive()) never learns its name, so call __set_name__ yourself or avoid that pattern.

  • ▸

    A descriptor that defines only __set__ (no __get__) is legal. Reading then falls through to the instance __dict__, which is the trick for 'validate on write, plain fast read' attributes. Descriptors are found only on the type; super().attr and the class-level Cls.attr both run __get__ too, with obj=None in the class case. That obj is None convention is what lets property objects, functions and cached_property be reached from the class (for example Item.price.fget).

  • ▸

    For validation across a whole codebase, prefer a library (Pydantic, attrs) that is built on these tools, instead of hand-written descriptors. Writing one is still the best way to learn what those libraries do, and it is a common senior interview question.

  • ▸

    Type checkers understand property and well-typed descriptors (__get__ overloads). A hand-written descriptor needs overloads or generics for the types to flow, or the checker sees Any.

Remember this

  1. 1

    A descriptor is any object whose class defines __get__(self, obj, objtype), and optionally __set__(self, obj, value), __delete__(self, obj) and __set_name__(self, owner, name). For it to work, an instance of it must be stored as a class attribute (one variable shared by the whole class, Topic 8.2) of another class. Then obj.attr calls descriptor.__get__(obj, type(obj)), and obj.attr = v calls descriptor.__set__(obj, v). When you read it on the class itself (Item.price), obj is None, and by convention __get__ returns the descriptor.

  2. 2

    __set_name__(owner, name) is called once, when the class statement runs, with the class and the attribute name the descriptor was assigned to. It lets one descriptor class serve many attributes without repeating the name by hand: price = Positive() learns that it is called price. It arrived in Python 3.6.

  3. 3

    Descriptors come in two kinds. A data descriptor defines __set__ or __delete__ (as well as __get__). A non-data descriptor defines only __get__. The kind decides the winner in the lookup from Topic 13.4: a data descriptor on the type beats the instance's __dict__, while a non-data descriptor loses to an entry in the instance's __dict__. This single rule is why you cannot overwrite a property by assigning into the instance dict, and why you can shadow a method with an instance attribute.

  4. 4

    Much of Python's 'magic' is descriptors. A function is a non-data descriptor: its __get__ returns a bound method. property is a data descriptor whose __get__, __set__ and __delete__ call the functions you gave it (a property with no setter still defines __set__, which raises AttributeError). classmethod and staticmethod (Topic 8.3) are non-data descriptors that change what __get__ returns. functools.cached_property is a non-data descriptor that computes the value once and writes it into the instance's __dict__, so later reads never reach the descriptor again.

  5. 5

    A descriptor is shared by all instances, because it lives on the class. So it must never store per-instance data on itself (self.value = v would be shared by every object). Store the value on the instance, usually in obj.__dict__[name] or a private attribute whose name comes from __set_name__. This is the most common descriptor bug.

  6. 6

    Use a descriptor when the same attribute behaviour (validate, convert, log, lazily compute) is needed on several attributes or several classes. For one attribute in one class, property is simpler. Frameworks use descriptors heavily: Django model fields, SQLAlchemy columns, dataclasses.field handling, and validators in Pydantic-style libraries all rely on this protocol.

Explain it without notes

01

What is a descriptor, and what are the methods of the protocol?

02

What is the difference between a data descriptor and a non-data descriptor?

03

How does functools.cached_property work?

04

Why must a descriptor not store per-instance state on itself?

05

How does the descriptor protocol explain why self is passed automatically in obj.method(), and why Cls.method is a plain function?

Practice

01

Write an Upper descriptor class that stores any string assigned to it in upper case (use __set_name__ to learn the attribute name). Make a class User with two such attributes, name and city, set them to "ada" and "london", and print both.

02

Write two descriptor classes, one with __get__ and __set__ and one with only __get__. Put both on a class, write a string with the same name into the instance's __dict__ for each, and print what each attribute returns. Say which descriptor won and which lost.

03

Write a descriptor Counted whose __get__ counts how many times each instance read the attribute (keep the count in the instance's __dict__). Read it three times and print the instance's __dict__.

Trade-offs

  • ↔

    One descriptor, many attributes. It removes repetition, but it adds indirection that beginners find hard to read. For one attribute, a property is simpler.

  • ↔

    Descriptors run Python code on every access. A validating __set__ is much slower than a plain attribute store. That is fine for models and settings, and wrong for the inner loop of a numeric routine.

Done when you can

  • Done when you can write a descriptor with __get__, __set__ and __set_name__ and explain each.

  • Done when you can explain data versus non-data descriptors and give a built-in example of each.

  • Done when you can rebuild property and cached_property from memory.

  • Done when you can explain why a method is a descriptor and what __get__ returns for it.

  • Done when you can say where a descriptor must store its per-instance values.