Descriptors & Properties: The Attribute Protocol
1 · The lesson
readEvery time you write obj.x, Python runs an algorithm. Most of the time it's "look in obj.__dict__, fall back to the class." But if the class attribute is a descriptor, Python calls that descriptor's __get__ method, and the descriptor decides what obj.x returns.
That one hook is the basis of @property, @classmethod, @staticmethod, method binding (the reason self works), functools.cached_property, ORM field types (Django models.CharField, SQLAlchemy Column), and other features you have used without knowing it. This lesson explains the protocol, so you can see how those features work.
1. The Descriptor Protocol in One Sentence
A descriptor is any object whose class implements at least one of:
__get__(self, obj, objtype): runs when the attribute is read.__set__(self, obj, value): runs when the attribute is assigned.__delete__(self, obj): runs when the attribute is deleted.
That is all. Define one of those methods, put an instance of your class as a class attribute on another class, and every read or write of that attribute on instances runs your code.
class Const: def __get__(self, obj, objtype=None): return 42 class Foo: answer = Const() # descriptor instance, attached at class-body level f = Foo() print(f.answer) # 42 (Const.__get__ ran) print(Foo.answer) # 42 (this works on the class too)
Three names to keep straight in __get__:
self: the descriptor instance (theConst()).obj: the instance the attribute is being accessed on (theFoo()), orNoneif accessed on the class.objtype: the class (Foo).
2. __set_name__: Knowing Your Own Name
When you write x = Positive() inside a class body, the descriptor doesn't know it was named x. Python 3.6 added __set_name__, which runs when the class is created with the owner class and the attribute name:
class Tagged: def __set_name__(self, owner, name): print(f"attached to {owner.__name__} as {name!r}") self.name = name class Foo: a = Tagged() b = Tagged() # attached to Foo as 'a' # attached to Foo as 'b'
Any validating descriptor that stores per-instance state is built on this. You use name as the key for the value inside obj.__dict__ (see Section 3).
3. A Validating Descriptor: Positive
A descriptor that raises if you try to set a non-positive value:
class Positive: def __set_name__(self, owner, name): self._name = name # remember the attribute name def __get__(self, obj, objtype=None): if obj is None: # access on the class itself return self return obj.__dict__[self._name] def __set__(self, obj, value): if value <= 0: raise ValueError(f"{self._name} must be > 0, got {value}") obj.__dict__[self._name] = value class Order: quantity = Positive() price = Positive() def __init__(self, quantity, price): self.quantity = quantity # routes through Positive.__set__ self.price = price o = Order(3, 19.99) print(o.quantity, o.price) # 3 19.99 o.quantity = 5 # validated and stored # o.quantity = -1 # ValueError: quantity must be > 0, got -1 # Order(0, 10) # ValueError: quantity must be > 0, got 0
Two things to notice:
- State lives on the instance, not on the descriptor. We write into
obj.__dict__[self._name]. OnePositive()is shared by everyOrder, and only the value is per-instance. - The
obj is Nonecheck in__get__: when someone writesOrder.quantity(on the class, not an instance), return the descriptor itself, so introspection still works.
4. Data vs Non-Data Descriptors: The Lookup Order
Python's attribute lookup has a specific priority:
1. Data descriptors on the class (they have __set__ or __delete__). These come first.
2. The instance __dict__.
3. Non-data descriptors on the class (only __get__). The instance dict comes before them.
4. Class attributes (regular values).
class Loud: def __get__(self, obj, objtype=None): return "loud!" class Quiet: def __get__(self, obj, objtype=None): return "quiet" def __set__(self, obj, value): obj.__dict__["q"] = value class Foo: l = Loud() # non-data descriptor q = Quiet() # data descriptor f = Foo() f.__dict__["l"] = "shadowed" # instance dict wins over non-data descriptor f.__dict__["q"] = "shadowed" # data descriptor still wins print(f.l) # shadowed print(f.q) # quiet
This is why a function (a non-data descriptor, because it only defines __get__) can be replaced on one instance by putting something in instance.__dict__, but a @property (a data descriptor) cannot.
5. Methods Are Descriptors
Functions defined with def implement __get__. This is how method binding works. self is not a keyword. The descriptor protocol passes the instance in as the first argument:
class Foo: def hello(self): return f"hi from {self}" f = Foo() print(Foo.hello) # <function Foo.hello at 0x...> (the plain function) print(f.hello) # <bound method Foo.hello of ...> (the descriptor made a bound method) # What actually happens: import types manually_bound = Foo.hello.__get__(f, Foo) print(manually_bound) # <bound method Foo.hello of ...> print(manually_bound()) # hi from <__main__.Foo object>
Foo.hello.__get__(f, Foo) returns a types.MethodType with f already bound as the first argument. Every method call goes through this: f.hello() is Foo.hello.__get__(f, Foo)().
Methods are non-data descriptors (no __set__), which is why you can replace a method on one instance by assigning to instance.method = ... (this is called monkey-patching). The instance dict comes first.
6. @property IS a Descriptor
property is a built-in descriptor class. The decorator form is a shorter way to use it:
class C: @property def area(self): return self._r ** 2 * 3.14159
is roughly equivalent to:
class _AreaDescriptor: def __get__(self, obj, objtype=None): if obj is None: return self return obj._r ** 2 * 3.14159 class C: area = _AreaDescriptor()
The real property is a single class that takes fget, fset and fdel as __init__ arguments. @property.setter builds a new property object with the setter filled in. Once you know this, you can answer questions like "why does my property setter stop working after I redefine the getter in a subclass?"
@classmethod and @staticmethod are also descriptors. classmethod.__get__ returns a method bound to the class instead of the instance, and staticmethod.__get__ returns the underlying function unbound. You will almost never need to know this. But it explains why all three "method types" are defined in the class body and all work when you access them.
7. A Real-World Descriptor: Lazy
Compute a value once on first access, then cache it on the instance for every later read. The method: write into obj.__dict__ from __get__. Normal attribute lookup checks the instance dict before non-data descriptors, so on the next access Python finds the cached value there, and your __get__ never runs again.
class Lazy: def __init__(self, func): self.func = func def __set_name__(self, owner, name): self._name = name def __get__(self, obj, objtype=None): if obj is None: return self value = self.func(obj) obj.__dict__[self._name] = value # cached here, so the next access skips the descriptor return value class Dataset: def __init__(self, rows): self.rows = rows @Lazy def summary(self): print("computing summary (expensive)...") return { "count": len(self.rows), "total": sum(self.rows), } d = Dataset([1, 2, 3, 4, 5]) print(d.summary) # computing summary (expensive)... {'count': 5, 'total': 15} print(d.summary) # {'count': 5, 'total': 15} (not computed again) print(d.summary) # the same result again
The standard library already has this. Use functools.cached_property:
from functools import cached_property class Dataset: def __init__(self, rows): self.rows = rows @cached_property def summary(self): return {"count": len(self.rows), "total": sum(self.rows)}
Always prefer the standard library version. It handles __set_name__ for you and gives a clear error on classes with __slots__ (it stores the value in the instance __dict__). It is not thread-safe: since Python 3.12 it has no lock. Writing your own Lazy is a teaching exercise, but importing cached_property is the answer in production.
8. When to Write Your Own Descriptor
A simple rule:
- One attribute, one class, simple logic →
@property. - The same validation logic on many attributes across many classes → descriptor.
Five @propertys with copy-pasted validation are a sign that you need a descriptor. One Typed(int) descriptor used on quantity, count, index, age across Order, Inventory, Page, User is better: you write the validation once and use it everywhere.
Real-world descriptor users:
- Django ORM: on a model instance, fields such as
models.CharField(max_length=100)are read through descriptors. - SQLAlchemy:
Column(...)andrelationship(...)on a mapped class become descriptors that load and track the values.
You'll mostly configure these. You'll occasionally write a small one for a project-specific validation rule. If you need more than that, you are probably building a framework.
9. Descriptors and dataclasses
Dataclass fields work with the descriptor protocol. If you use a descriptor as a field's default value, the __init__ that the dataclass generates goes through it:
from dataclasses import dataclass class NonEmpty: def __set_name__(self, owner, name): self._name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__[self._name] def __set__(self, obj, value): if not value: raise ValueError(f"{self._name} cannot be empty") obj.__dict__[self._name] = value @dataclass class User: name: str = NonEmpty() email: str = NonEmpty() u = User("Linus", "linus@example.com") print(u) # User(name='Linus', email='linus@example.com') # User("", "x@y") # ValueError: name cannot be empty
A descriptor lets you add validation to a dataclass. For the simple case "this field must follow a rule", it is clearer than __post_init__.
Common Mistakes
1. Storing per-instance state on the descriptor itself (a common bug)
class Bad: def __get__(self, obj, objtype=None): return self.value def __set__(self, obj, value): self.value = value # WRONG: this stores the value on the descriptor, # so every Foo object shares one value! class Foo: x = Bad() a, b = Foo(), Foo() a.x = 1 b.x = 2 print(a.x) # 2 (a and b share one value!)
The descriptor is one object held as a class attribute. Store the value in obj.__dict__[self._name] instead, which belongs to each instance.
2. Confusing obj and objtype in __get__
obj is the instance (or None if accessed on the class). objtype is the class. Mixing them up gives you methods that return class-level data when you wanted instance-level (or vice versa). Always handle the obj is None case explicitly: return self, so introspection on the class still works.
3. Forgetting __set_name__ and manually passing names
class Field: def __init__(self, name): # easy to break, because name must match the attribute self.name = name class Foo: x = Field("x") # the name "x" is typed twice y = Field("y") # easy to mistype
__set_name__ receives the name automatically when the class is created. Use it. Code written before Python 3.6 passes names by hand because there was no other way, but new code shouldn't.
4. Writing a descriptor when a @property would do
A descriptor used in exactly one place, by one attribute, on one class, is too much. @property is shorter, more idiomatic, and easier for the next person to read. Change it to a descriptor only when you would be writing the same logic for the third time.
5. Expecting descriptors to fire on dict-style access
class Foo: x = SomeDescriptor() f = Foo() f.x # the descriptor runs f.__dict__["x"] = 42 # a direct dict write skips the descriptor print(f.__dict__) # {'x': 42}
setup added so this can run · defines SomeDescriptor
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def SomeDescriptor(*_a, **_kw): print('-> SomeDescriptor() called') return _AutoMock('SomeDescriptor()')
Descriptors hook into normal attribute access (f.x = ...). Reaching directly into __dict__ skips them. This is usually useful (the descriptor itself uses it to store state). But it causes bugs when test code or other code writes to __dict__ by accident. (setattr does not skip the descriptor. It works like f.x = ....)
🎯 Your Turn: Build a Typed Descriptor
Build Typed(expected_type), a descriptor that enforces a type every time the attribute is set, raising TypeError with a clear message that includes the attribute name. Requirements:
name = Typed(str)andage = Typed(int)should work as class attributes.- Setting a value of the wrong type raises
TypeError: <attr_name> must be <type>, got <actual_type>. - Setting a value of the correct type stores it per-instance (no shared state between instances).
- Reading returns the stored value. Reading before any set should raise
AttributeError(Python's default for missing attributes). - Use
__set_name__so you don't have to pass the name in manually.
class Typed: def __init__(self, expected_type): # TODO 1: store expected_type ... def __set_name__(self, owner, name): # TODO 2: remember the attribute name ... def __get__(self, obj, objtype=None): # TODO 3: if obj is None, return self # TODO 4: pull the value from obj.__dict__ ... def __set__(self, obj, value): # TODO 5: check the type with isinstance, raise TypeError if wrong # TODO 6: store in obj.__dict__[self._name] ... class Person: name = Typed(str) age = Typed(int) def __init__(self, name, age): self.name = name self.age = age p = Person("Linus", 28) print(p.name, p.age) # Person(123, 28) # TypeError: name must be <class 'str'>, got <class 'int'>
Hint 1: Per-instance storage in obj.__dict__
Inside __set__, write obj.__dict__[self._name] = value. Inside __get__, read it back the same way. Never write self.value = ... on the descriptor. That is the shared-state bug from Common Mistake #1.
Hint 2: Using __set_name__ for the attribute name
Python calls __set_name__(self, owner, name) automatically when the class body finishes. Save name as self._name so your error messages and the __dict__ key can use it. No need for the user to pass the name as an argument.
Show full solution
class Typed: def __init__(self, expected_type): self.expected_type = expected_type def __set_name__(self, owner, name): self._name = name def __get__(self, obj, objtype=None): if obj is None: return self # access on the class try: return obj.__dict__[self._name] except KeyError: raise AttributeError( f"{type(obj).__name__!r} object has no attribute {self._name!r}" ) from None def __set__(self, obj, value): if not isinstance(value, self.expected_type): raise TypeError( f"{self._name} must be {self.expected_type}, got {type(value)}" ) obj.__dict__[self._name] = value class Person: name = Typed(str) age = Typed(int) def __init__(self, name, age): self.name = name self.age = age # Normal use, with valid values p = Person("Linus", 28) print(p.name, p.age) # Linus 28 p.age = 29 print(p.age) # 29 # Type errors try: Person(123, 28) except TypeError as e: print(e) # name must be <class 'str'>, got <class 'int'> try: p.age = "old" except TypeError as e: print(e) # age must be <class 'int'>, got <class 'str'> # Each instance keeps its own values (no shared state) p2 = Person("Ada", 36) print(p.name, p2.name) # Linus Ada (each object keeps its own name)
A few improvements you would add in real code:
boolsubclassesintin Python, soTyped(int)acceptsTrue/False. If you want to reject bools, addand not isinstance(value, bool).- Subclass acceptance:
isinstanceaccepts subclasses by design.Typed(Animal)will accept anyDog. This is usually what you want, but not always, in which case usetype(value) is self.expected_type. - Tuples of types:
isinstanceaccepts(int, float), meaning "any of these". SoTyped((int, float))already works without changing a line. - Default values: supply a
default=argument in__init__and return it from__get__when the key is missing, instead of raisingAttributeError.
Libraries such as Pydantic, attrs and Django (models.CharField) give you typed or validated fields with many more features. The basic idea is the one you just built: check the value when it is set, and store it per instance.
What You Learned
- A descriptor is any object whose class defines
__get__,__set__, or__delete__. It controls attribute access on instances of the class that holds it as a class attribute. __set_name__(self, owner, name)(3.6+) gives the descriptor its own attribute name when the class is created. Validating descriptors depend on it.- Data descriptors (with
__set__) come before the instance__dict__. Non-data descriptors (only__get__) come after it. That's why you can replace a method on one instance, but not a@property. - Functions are non-data descriptors: their
__get__returns a method withselfalready bound. This is how method calls work. @property,@classmethod,@staticmethodare all built-in descriptor classes. The decorator form is a shorter way to use them.functools.cached_propertyis the standard library descriptor for "compute once, cache on the instance". Always prefer it over writing your ownLazy.- Store per-instance state in
obj.__dict__[self._name], never onself(the descriptor itself is shared across every owning instance). - Write your own descriptor only when the same logic is repeated across many attributes across many classes. Otherwise
@propertyis enough.
Next: Context Managers: the with protocol, __enter__/__exit__, and contextlib for resource-safe code that cleans up on every path.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.