Python Module 18 – Advanced Python: data model, metaprogramming and internals
Descriptors: how properties and methods work
Write Python descriptors with __get__, __set__ and __set_name__, tell data from non-data descriptors, and see how properties and methods are built on them.
What you will learn
- Implement a descriptor with __get__, __set__ and __set_name__ that stores a value per instance
- Distinguish data descriptors from non-data descriptors and predict which one beats the instance dictionary
- Explain how a function stored in a class becomes a bound method through __get__
- Build reusable validated fields for a model class
Before you start
What you’ll learn · Before you start
What you will learn
- Implement a descriptor with __get__, __set__ and __set_name__ that stores a value per instance
- Distinguish data descriptors from non-data descriptors and predict which one beats the instance dictionary
- Explain how a function stored in a class becomes a bound method through __get__
- Build reusable validated fields for a model class
Before you start
On this page
qty = Positive() is one line in a class body, and from then on every assignment to qty, the one in __init__
included, is checked: setting it to 0 raises ValueError and keeps the old value. Positive is a descriptor, an
object stored as a class attribute that decides what happens when that attribute is read, assigned or deleted through
an instance. It does so by defining __get__, and optionally __set__ and __delete__: when the
attribute lookup: What Python does to evaluate obj.name: the default __getattribute__ looks for a data descriptor on the class, then in the instance's own dictionary, then for anything else on the class, and only when all three fail is __getattr__ called. More in “Attribute lookup and the data model” of the previous lesson finds such an object on the class, it calls
the method instead of handing the object back.
Properties work this way, and so do methods: a function stored in a class is a descriptor whose __get__ returns a
bound method, which is where self comes from. A fourth method, __set_name__, tells a descriptor the name it was
given when its class was created. Below you will build such fields, sort descriptors into two kinds, and bind a method
by hand.
A descriptor turns every use of a field into a method call
fields.py declares three fields on an Order, builds two orders, and then tries three bad values on the first:
class Field:
"""Keeps each instance's value in that instance, under the field's name."""
def __set_name__(self, owner, name): # called once, when the owning class is created
self.name = name
def __get__(self, obj, objtype=None):
if obj is None: # read through the class: the descriptor itself
return self
try:
return obj.__dict__[self.name]
except KeyError:
raise AttributeError(self.name) from None
def __set__(self, obj, value):
self.check(value) # refuse a bad value before storing it
obj.__dict__[self.name] = value
def check(self, value):
pass
class Positive(Field):
def check(self, value):
if value <= 0:
raise ValueError(f"{self.name} must be greater than 0, got {value}")
class MaxLength(Field):
def __init__(self, limit):
self.limit = limit
def check(self, value):
if len(value) > self.limit:
raise ValueError(f"{self.name} has at most {self.limit} characters, got {len(value)}")
class Order:
item = MaxLength(12)
qty = Positive()
price = Positive()
def __init__(self, item, qty, price):
self.item = item # each assignment runs a __set__
self.qty = qty
self.price = price
a = Order("chai", 3, 40)
b = Order("samosa", 2, 25)
print(a.qty * a.price, b.qty * b.price, vars(a))
for field, bad in (("qty", 0), ("price", -5), ("item", "masala dosa combo")):
try:
setattr(a, field, bad)
except ValueError as e:
print("ValueError:", e)
print(vars(a), type(Order.qty).__name__, Order.qty.name) Output
120 50 {'item': 'chai', 'qty': 3, 'price': 40}
ValueError: qty must be greater than 0, got 0
ValueError: price must be greater than 0, got -5
ValueError: item has at most 12 characters, got 17
{'item': 'chai', 'qty': 3, 'price': 40} Positive qty
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fields.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
qty = Positive()andprice = Positive()put two descriptor objects in the class. When Python createsOrder, it calls__set_name__on each with the name it was assigned to, so each object knows which field it is.self.qty = qtyin__init__is an assignment through an instance. The class holds a data descriptor of that name, so Python calls its__set__instead of storing the value: the check runs in the constructor too, and a refused value leaves the old one in place.- The values live in the instances.
__set__writesobj.__dict__["qty"]and__get__reads it back, so each order keeps its own numbers. Using the field’s own name as the key is safe, because a data descriptor is consulted before the instance’s dictionary and the stored value can never hide it. - Read through the class,
Order.qtycalls__get__withNonefor the instance, and the field returns itself, which lets tests and tools inspect it.
Follow those calls one message at a time. The middle column is the qty field, the one Positive object that Order
holds for that name:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The class is created. When the body of Order has run, type.__new__ calls __set_name__ on each field it finds. The qty field records the name it was assigned to.
- An assignment in __init__. Order('chai', 3, 40) runs self.qty = qty in __init__. The class holds a data descriptor called qty, so Python calls its __set__ instead of storing the value.
- Checked, then stored. 3 is greater than 0, so the check passes and __set__ writes the value into the order itself, under the field's name.
- A read. a.qty calls __get__(a, Order), which reads vars(a)['qty'] back. It gives 3, so a.qty * a.price is 120.
- A bad value. setattr(a, 'qty', 0) calls __set__(a, 0). The check raises ValueError before anything is stored, so vars(a) still holds 3.
- Through the class. Order.qty calls __get__(None, Order). With no instance to read from, the field returns itself, which is how the last line printed its class, Positive, and its name, qty.
The class holds one descriptor object per field, and every order keeps its own values in its own dictionary:
The descriptor lives in the class, the values live in the instances
Text description of the diagram
The diagram has three levels, from top to bottom.
- At the top is the class Order, whose body assigns qty = Positive() and price = Positive(). The class holds one descriptor object for each field.
- Below it is the Positive object for qty. When the class was created, Python called its __set_name__ method, which recorded the name 'qty'.
- At the bottom are two orders, a and b. Each has its own dictionary, with 'qty': 3 in a and 'qty': 2 in b.
- Arrows lead from the descriptor to both orders: its __get__ method reads the value from the order it is called for, and its __set__ method checks a new value and writes it there. The same descriptor object serves every instance, which is why the values must not be stored on the descriptor itself.
Each line of code that touches the field becomes one call on its descriptor, here named d:
| You write | Python calls |
|---|---|
class Order: finishes |
d.__set_name__(Order, "qty") |
order.qty |
d.__get__(order, Order) |
order.qty = 5 |
d.__set__(order, 5) |
del order.qty |
d.__delete__(order), if defined |
Order.qty |
d.__get__(None, Order) |
Checkpoint
Check: The calls a field receives
Data or non-data: which one beats the instance
kinds.py asks each built-in kind which of the three methods its class defines:
import functools
class Point:
__slots__ = ("x", "y")
class Sample:
def method(self): ...
@classmethod
def build(cls): ...
@staticmethod
def helper(): ...
@property
def size(self): ...
@functools.cached_property
def summary(self): ...
objects = {
"function": Sample.__dict__["method"],
"classmethod": Sample.__dict__["build"],
"staticmethod": Sample.__dict__["helper"],
"property": Sample.__dict__["size"],
"cached_property": Sample.__dict__["summary"],
"slot": Point.__dict__["x"],
}
print(f"{'stored in the class':20} {'__get__':8} {'__set__':8} {'__delete__':11} kind")
for label, obj in objects.items():
has = [hasattr(type(obj), m) for m in ("__get__", "__set__", "__delete__")]
kind = "data" if has[1] or has[2] else "non-data"
print(f"{label:20} {str(has[0]):8} {str(has[1]):8} {str(has[2]):11} {kind}") Output
stored in the class __get__ __set__ __delete__ kind function True False False non-data classmethod True False False non-data staticmethod True False False non-data property True True True data cached_property True False False non-data slot True True True data
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 kinds.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A descriptor that defines __set__ or __delete__ is a data descriptor: A descriptor that defines __set__ or __delete__, such as a property or a slot. Attribute lookup checks it before the instance's dictionary, so an instance attribute of the same name can never hide it., and it
always beats an instance attribute of the same name. One that defines only __get__ is a
non-data descriptor: A descriptor that defines only __get__, such as a function or functools.cached_property. An entry of the same name in the instance's dictionary hides it, which is how an instance attribute can shadow a method., and an entry in the instance’s dictionary hides it. The run
shows the split: properties and the slots of __slots__ are data descriptors, while functions, class methods, static
methods and functools.cached_property are not. Each choice is deliberate. A property must run its setter even if
something wrote its name into the instance, while cached_property stores its result in the instance on purpose, so
that the next read finds the value there and never calls the descriptor again.
Step through two descriptors that differ in one method, and watch what the same entry in the instance’s dictionary does to each:
Program · who_wins.py
class Ten:
def __get__(self, obj, owner):
return 10
class Fixed(Ten):
def __set__(self, obj, value):
raise AttributeError("fixed")
class A:
x = Ten()
y = Fixed()
a = A()
own = vars(a)
own["x"] = 5
own["y"] = 5
x = a.x
y = a.y
a.x = 7 Variables
- changed in this step
Printed so far
The program prints nothing.
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 11 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
class Ten:makes the classTen. Its body runs only when it is used. - Line 6.
class Fixed(Ten):makes the classFixed. Its body runs only when it is used.Fixedinherits__get__fromTenand adds__set__. That one method makes it a data descriptor. - Line 11.
class A:makes the classA. Its body runs only when it is used. - Line 16.
a = A()setsato<A object>. - Line 17.
own = vars(a)setsownto{}. - Line 18.
own["x"] = 5adds the key'x'toown, with the value5. - Line 19.
own["y"] = 5adds the key'y'toown, with the value5. Both names are now keys ofown, written straight into the dictionary, past every descriptor. - Line 20.
x = a.xsetsxto5. Key pointxis a non-data descriptor, so the instance's dictionary is searched before it, anda.xgives the 5 inown. - Line 21.
y = a.ysetsyto10. Key pointyis a data descriptor, checked before the dictionary:a.ygives 10 from__get__, and the 5 inownis never read. - Line 22.
a.x = 7akeeps its value,<A object>. That was the last line, so the program ends.a.x = 7found no__set__forx, so the value went intoown. The same assignment toywould have calledFixed.__set__, which refuses.
Key idea
With __set__ or __delete__, a descriptor beats the instance’s dictionary, as every property does; with only
__get__, the dictionary beats it, which is how an instance attribute can hide a method.
Checkpoint
Check: Data and non-data descriptors
A function binds itself to the instance it is read through
Methods use the same machinery. Step through a function bound by hand; each variable shows its parameters, so you can
see self being filled in:
Program · bound_method.py
import types
class Greeter:
def __init__(self, name):
self.name = name
def greet(self, other):
return f"{self.name} greets {other}"
g = Greeter("Asha")
f = vars(Greeter)["greet"]
has = hasattr(f, "__get__"), hasattr(f, "__set__")
bound = f.__get__(g, Greeter)
same = bound.__self__ is g
print(bound("Ravi"), "|", g.greet("Ravi"))
plain = f.__get__(None, Greeter)
by_hand = types.MethodType(f, g)
print(plain is f, by_hand("Meena"))
own = vars(g)
g.greet = lambda other: f"hello {other}"
print(g.greet("Ravi"), "|", Greeter.greet(g, "Ravi")) Variables
- changed in this step
Printed so far
Nothing printed yetAsha greets Ravi | Asha greets Ravi True Asha greets Meena hello Ravi | Asha greets Ravi
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 15 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
import typesloadstypes. - Line 4.
class Greeter:makes the classGreeter. Its body runs only when it is used. - Line 12.
g = Greeter("Asha")setsgto<Greeter object>. - Line 13.
f = vars(Greeter)["greet"]setsftogreet(self, other). The class stores a plain function, and its parameters are(self, other): nothing has bound it yet. - Line 14.
has = hasattr(f, "__get__"), hasattr(f, "__set__")makeshasa tuple of 2 items.hasis(True, False): the function has__get__but no__set__, so it is a non-data descriptor. - Line 15.
bound = f.__get__(g, Greeter)setsboundtogreet(other). Key pointf.__get__(g, Greeter)is exactly what readingg.greetdoes. The result takes onlyother, becausegis already itsself. - Line 16.
same = bound.__self__ is gsetssametoTrue. - Line 17.
print(bound("Ravi"), "|", g.greet("Ravi"))printsAsha greets Ravi | Asha greets Ravi. - Line 18.
plain = f.__get__(None, Greeter)setsplaintogreet(self, other). Read through the class,__get__receivesNonefor the instance and returns the function itself, soplain is f. - Line 19.
by_hand = types.MethodType(f, g)setsby_handtogreet(other). - Line 20.
print(plain is f, by_hand("Meena"))printsTrue Asha greets Meena. - Line 21.
own = vars(g)setsownto{'name': 'Asha'}. - Line 22.
g.greet = lambda other: f"hello {other}"gkeeps its value,<Greeter object>. Key pointgreetis now a key ofown. A function is a non-data descriptor, so this entry hides the method forg, and forgonly. - Line 23.
print(g.greet("Ravi"), "|", Greeter.greet(g, "Ravi"))printshello Ravi | Asha greets Ravi. That was the last line, so the program ends.
- The class stores a plain function, which has
__get__and no__set__: a non-data descriptor. f.__get__(g, Greeter)returns a bound method whose__self__isg, which is exactly what readingg.greetproduces, andtypes.MethodTypebuilds the same object by hand.- Read through the class,
__get__receivesNoneand returns the function itself, which is whyGreeter.greet(g, "Ravi")needs the instance passed in. - Because functions are non-data descriptors, assigning
g.greetgives that one instance a callable of its own, which hides the method.
The same four methods explain the rest of the machinery. property is a data descriptor that can be rebuilt in Python
in about thirty lines. classmethod binds its function to the class, and staticmethod does not bind at all. A
descriptor that keeps values on itself shares one value between every instance of its class. The value can also live
in the instance under a private name, or in a weakref.WeakKeyDictionary that the descriptor holds. __set_name__
runs only while a class is being created, so a descriptor attached later never learns its name.
Checkpoint
Check: Functions as descriptors
Practise: fields, kinds and bound methods
These questions cover the three ideas above, the one you have missed most first, and say why each answer is right or wrong as soon as you choose it.
Practice that adapts to you
Practise this lesson
7 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
property rebuilt and checked against the built-in, then the other kinds opened up Pro: opens with a Pro, Premium or Ultimate pass
Each built-in descriptor is a few lines of Python once the four methods are clear. This part rebuilds property and
checks it against the built-in line by line, rebuilds classmethod and staticmethod, opens up what __slots__ and
cached_property put in a class, and records the arguments of every way a descriptor is called.
In this part:
- property in thirty lines, compared with the built-in
- Class methods, static methods, slots and cached properties, opened up
- Three ways a descriptor is called: instance, class and super()
1 diagram · 3 runnable examples (Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Fields that hold up: the shared-value bug, four storage choices counted, and an exercise Pro: opens with a Pro, Premium or Ultimate pass
One descriptor object serves every instance of its class, so where it keeps per-instance values decides whether a
field works at all. This part reproduces the shared-value bug, counts what four storage choices leave behind, shows
two traps in the timing of __set_name__, and ends with an exercise that builds a small model layer.
In this part:
- One value for every order: the shared-value bug, reproduced
- Four places to keep the value, and what each leaves behind, counted
- A field’s name is set once, at class creation: two traps reproduced
- Error messages that name the right class, and rules that span two fields
- Exercise: Integer, String and Choice fields for a model class
- Integer, String and Choice fields for a small model class
1 diagram · 3 runnable examples (Python) · 1 exercise with sample tests · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What is the difference between a data descriptor and a non-data descriptor?
Both are objects stored as class attributes with a __get__ method, which attribute lookup calls instead of returning the object. A data descriptor also defines __set__ or __delete__, and it takes priority over the instance's own dictionary for reads and writes: a property is the usual example, which is why an instance attribute can never hide a property. A non-data descriptor defines only __get__, and an entry of the same name in the instance's dictionary hides it. Functions are non-data descriptors, which is why an instance attribute can shadow a method, and functools.cached_property relies on the same rule: its first read stores the result in the instance's dictionary, and later reads find it there without calling the descriptor again.
-
Mid to senior
Twelve model classes have 40 properties between them, and almost all check "a positive number" or "a string of at most N characters". A teammate proposes descriptor classes instead. Make the case for and against, and say what you would do.
-
Senior to staff
Design the column attribute of a small ORM so that User.email gives an object you can build queries with, while user.email gives that user's value. Which descriptor methods do you write, what does each receive, and what breaks with __slots__ or with a subclass that redefines the column?
Key takeaways
- A descriptor is a class attribute with
__get__, and optionally__set__and__delete__; the attribute lookup calls these methods instead of returning the object. - With
__set__or__delete__it is a data descriptor and beats the instance’s dictionary; with only__get__the instance wins. - Functions are non-data descriptors whose
__get__returns bound methods; properties are data descriptors. __set_name__gives each field its name while the class is created, so one field class serves many attributes.- Keep per-instance values in the instance, never on the descriptor, which every instance shares.
Terms in this lesson
- data descriptor
- A descriptor that defines __set__ or __delete__, such as a property or a slot. Attribute lookup checks it before the instance's dictionary, so an instance attribute of the same name can never hide it.
- descriptor
- An object stored as a class attribute whose class defines __get__, and optionally __set__ or __delete__. Attribute lookup calls these methods instead of returning the object; properties, methods and __slots__ all work this way.
- non-data descriptor
- A descriptor that defines only __get__, such as a function or functools.cached_property. An entry of the same name in the instance's dictionary hides it, which is how an instance attribute can shadow a method.
References
- Descriptor Guide (Python Software Foundation)
- Descriptor Guide: pure Python equivalents (Python Software Foundation)
- The Python Language Reference: implementing descriptors (Python Software Foundation)
- The Python Language Reference: invoking descriptors (Python Software Foundation)
- The Python Language Reference: object.__set_name__() (Python Software Foundation)
- functools.cached_property (Python Software Foundation)
- weakref.WeakKeyDictionary (Python Software Foundation)
- PEP 487: simpler customisation of class creation (__set_name__) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress