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
Nonewhen 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.
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)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.
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.
Try it yourself
- 1
Add a maximum
In the first example make
Positiveaccept an optionalmaximumin__init__(price = Positive(maximum=1000)) and reject larger values. Check thatquantity = Positive()still works with no limit. - 2
Reset the cache
In the second example run
del c.area, then readc.areaagain and printCircle.calls. The cache lived in the instance dict, so deleting the entry made the descriptor compute again. - 3
Share the value by mistake
Change
Positive.__set__to store the value on the descriptor (self.value = value) and__get__to returnself.value. Createaandbwith 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
Expected output
10 3 99
['_price', '_quantity']
Positive
ValueError: price must be positive, got -5
10Real code should use the built-in `property` and `functools.cached_property`. This shows how they work underneath.
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 CircleExpected output
from data descriptor
instance nBreak 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.
class Circle:
def __init__(self, r):
self.r = r
@property
def area(self):
return 3 * self.r ** 2
c = Circle(2)
c.area = 50Myth 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_propertyneeds 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.fieldand 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().attrand the class-levelCls.attrboth run__get__too, withobj=Nonein the class case. Thatobj is Noneconvention is what letspropertyobjects, functions andcached_propertybe reached from the class (for exampleItem.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
propertyand well-typed descriptors (__get__overloads). A hand-written descriptor needs overloads or generics for the types to flow, or the checker seesAny.
Remember this
- 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. Thenobj.attrcallsdescriptor.__get__(obj, type(obj)), andobj.attr = vcallsdescriptor.__set__(obj, v). When you read it on the class itself (Item.price),objisNone, and by convention__get__returns the descriptor. - 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 calledprice. It arrived in Python 3.6. - 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
Much of Python's 'magic' is descriptors. A function is a non-data descriptor: its
__get__returns a bound method.propertyis a data descriptor whose__get__,__set__and__delete__call the functions you gave it (apropertywith no setter still defines__set__, which raisesAttributeError).classmethodandstaticmethod(Topic 8.3) are non-data descriptors that change what__get__returns.functools.cached_propertyis 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
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 = vwould be shared by every object). Store the value on the instance, usually inobj.__dict__[name]or a private attribute whose name comes from__set_name__. This is the most common descriptor bug. - 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,
propertyis simpler. Frameworks use descriptors heavily: Django model fields, SQLAlchemy columns,dataclasses.fieldhandling, and validators in Pydantic-style libraries all rely on this protocol.
Explain it without notes
What is a descriptor, and what are the methods of the protocol?
What is the difference between a data descriptor and a non-data descriptor?
How does functools.cached_property work?
Why must a descriptor not store per-instance state on itself?
How does the descriptor protocol explain why self is passed automatically in obj.method(), and why Cls.method is a plain function?
Practice
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.
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.
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
propertyis 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
propertyandcached_propertyfrom 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.