AlgoMaster Logo

Descriptors

Low Priority17 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

A descriptor is any object that controls what happens when its attribute is accessed on another object. If you've ever wondered how @property works, how methods get bound to instances, or how libraries like Django, SQLAlchemy, and Pydantic validate fields automatically, the answer is descriptors. This lesson covers the protocol, the attribute lookup order, the data vs non-data distinction, and how to build a reusable validating field for a Product class.

What a Descriptor Is

A descriptor is a class that implements at least one of three special methods: __get__, __set__, or __delete__. When you put an instance of that class on another class as an attribute, Python routes attribute access through those methods instead of just storing and returning a plain value.

Here is the smallest possible descriptor:

Notice the two important things. shirt.price did not return the LoudAttribute object itself, it called __get__ on it. And shirt.price = 99 did not replace the class attribute, it called __set__. The descriptor sits between the user and the value, and it can do whatever it wants in between, validate, transform, log, fetch from a database, anything.

The signatures matter:

MethodSignatureCalled when
__get____get__(self, instance, owner)Attribute is read
__set____set__(self, instance, value)Attribute is assigned
__delete____delete__(self, instance)Attribute is deleted with del
__set_name____set_name__(self, owner, name)Class is being created

instance is the object the attribute was accessed through (or None if accessed through the class). owner is the class itself. value is what was assigned. name is the attribute name the descriptor was bound to.

One critical rule: descriptors only fire when they live on a class, not when they live on an instance. If you put a descriptor in shirt.__dict__, Python will not call __get__ on it. The whole mechanism is wired through the class object's lookup chain.

The Attribute Lookup Order

When you write shirt.price, Python doesn't just glance at shirt.__dict__. It runs through a specific sequence, and descriptors get priority at two different points in that sequence. Understanding this order is the difference between "descriptors are magic" and "descriptors are predictable."

For reading an attribute on instance.attr, Python (roughly) does:

In plain words:

  1. Look at the type of instance and walk its MRO (method resolution order) looking for attr. If it finds a data descriptor (one with __set__ or __delete__), call its __get__ and return that. Done.
  2. Otherwise look in instance.__dict__. If attr is there, return that value directly.
  3. Otherwise go back to the type chain. If it finds a non-data descriptor (one with only __get__) or any other class attribute, return that (calling __get__ if it's a descriptor).
  4. Otherwise raise AttributeError.

This is implemented in C inside object.__getattribute__. You can read the equivalent Python in the official "Descriptor HowTo" guide on docs.python.org.

The thing to take away: data descriptors win against instance __dict__, but non-data descriptors lose to instance __dict__. That single asymmetry is what makes @property immutable from instance code but lets regular methods be overridden on a per-instance basis.

Data Descriptors vs Non-Data Descriptors

The terms are unfortunately bureaucratic, but the distinction is simple.

KindDefinesWins against instance.__dict__?Typical use
Data descriptor__set__ and/or __delete__ (and usually __get__)Yes, on both read and writeproperty, validating fields, ORM columns
Non-data descriptorOnly __get__No, instance dict can shadow itMethods, classmethod, staticmethod, functools.cached_property (kind of)

Here's the decision diagram for how a class designer should pick:

A concrete demonstration of the shadowing difference:

The name attribute returns the value stuffed into the instance dict because the non-data descriptor lost the lookup race. The price attribute still goes through the descriptor because the data descriptor won. This is exactly the behavior that lets Python use regular functions as non-data descriptors and still let subclasses or instances replace them at will.

__set_name__ for Clean Attribute Names

Descriptors usually need to know which attribute name they were assigned to, so they can store and fetch the underlying value cleanly. Before Python 3.6, you had to pass the name in by hand:

That repetition (writing "price" twice, "stock" twice) was a known annoyance. Python 3.6 added __set_name__, a hook the interpreter calls during class creation:

When Python finishes building the Product class object, it walks each class-level attribute. If that attribute has a __set_name__ method, it calls it with the class and the attribute name. The descriptor now knows it's the price field without anyone telling it twice.

A Validating Field Descriptor

Here is where descriptors stop being a curiosity and start paying rent. Suppose you want every Product in your e-commerce app to enforce that price is a non-negative number, stock is a non-negative integer, and name is a non-empty string. You could write the same if/raise block inside every method, or you could write it once as a descriptor and reuse it everywhere.

Try it out:

A few things are worth pointing out about that implementation.

The __init__ of the descriptor takes the validation rules, not a value. The descriptor lives once on the class. Each Product instance gets its own value stored under _price, _stock, etc. inside its own __dict__. That is why the validation can be configured once at class-definition time but run for every instance.

The __get__ method has a if instance is None: return self line. This handles the case where someone accesses Product.price (through the class, not an instance). Without that line, getattr(None, "_price") would explode with an unhelpful error. Returning self is the standard convention so that things like Product.price give you back the descriptor object (useful for introspection).

The __set__ method stores the validated value with setattr(instance, self.private_name, value). Critically, it does not call setattr(instance, self.name, value), that would recurse forever because self.name is the attribute the descriptor itself manages. The leading underscore in _price is a separate storage key.

Storage Strategies for Per-Instance State

The "where do I keep the value?" question has three common answers, and each has different trade-offs.

Strategy 1: Instance `__dict__` with a different key (what we used above).

Pros: simple, no extra data structures, the value lives with the instance. Cons: pollutes the instance's __dict__ with a _price key that isn't really meant for users.

Strategy 2: Instance `__dict__` with the same key as the descriptor.

This works only for data descriptors. The data descriptor wins the lookup, so instance.price calls __get__, which reads from instance.__dict__["price"]. There's no infinite loop because the read goes through __dict__ directly. Pros: the dict key matches the attribute name, very clean. Cons: confusing the first time you see it, and you cannot share this code with non-data descriptors because they would lose the lookup race.

Strategy 3: `WeakKeyDictionary` on the descriptor itself.

The descriptor keeps a per-instance dictionary keyed by the instance object. WeakKeyDictionary holds references that don't prevent garbage collection, so instances can still be collected normally. Pros: nothing leaks into the instance's __dict__, works with __slots__ classes that have no __dict__ at all. Cons: instances must be hashable, and the descriptor accumulates entries across every instance it ever sees.

StrategyPollutes instance dict?Works with __slots__?Extra storage
Different key in instance dictYes (with _name)NoNone
Same key in instance dictNo (key is the attr)NoNone
WeakKeyDictionaryNoYesOne dict per descriptor

For most code, the third strategy is overkill, and the first or second is plenty. Reach for WeakKeyDictionary when you specifically need slots compatibility, or when the descriptor manages an expensive resource you want to clean up automatically.

How property, classmethod, staticmethod, and Methods Use the Protocol

The descriptor protocol is not a niche tool. Most of the things that look magical about Python's class machinery are descriptors under the hood.

`property` is a data descriptor written in C. A simplified Python version:

That is the whole trick. @property is just a class that wraps the getter and setter functions and implements the descriptor protocol. The real property adds fdel, __doc__, and deleter, but the core is what's above.

Functions are non-data descriptors. When you write class Cart: def total(self): ..., the function total lives on the class. The function class itself defines __get__, which is what produces a "bound method" when you access cart.total:

The function's __get__ returns a MethodType object that has cart glued in as the first argument. That's why cart.total() knows what self is. And because functions are non-data descriptors (no __set__), you can shadow a method on a per-instance basis if you really want to:

This monkey-patching behavior is occasionally useful for mocking in tests and is essentially never useful in production code.

`classmethod` is a non-data descriptor whose __get__ returns a bound method where the first argument is the class itself rather than an instance:

`staticmethod` is also a non-data descriptor, even simpler: its __get__ returns the wrapped function untouched, throwing away instance and owner entirely.

The pattern is consistent: any time Python does something interesting when you read or write an attribute, look for a descriptor.

Slots, Metaclasses, and Where Descriptors Fit

__slots__ replaces the instance __dict__ with a fixed set of slot descriptors. Each slot is itself a data descriptor that reads and writes a specific memory offset. Your own descriptors still work fine on slotted classes, you just need to use the WeakKeyDictionary storage strategy because there's no instance __dict__ to write into.

Metaclasses can scan a class at creation time and collect every descriptor that was declared. Libraries like SQLAlchemy and Pydantic use this pattern: a metaclass walks cls.__dict__ looking for Field descriptors, records them in a _fields_ list, and uses that list later to drive serialization, schema generation, or database mapping. You don't need a metaclass to use descriptors, but the two combine well.

Common Pitfalls

A few patterns trip up almost everyone the first time they write a descriptor.

Putting the descriptor on the instance, not the class. This silently does nothing:

The descriptor only fires when it lives on the class. An instance-level descriptor is just a regular attribute that holds the descriptor object.

Forgetting that one descriptor is shared across all instances. If you store the value as an attribute of self inside the descriptor:

Every instance writes to the same self.value because there is only one descriptor object per class. Two Product instances would clobber each other's prices. The fix is one of the three storage strategies covered earlier.

Forgetting `__set_name__` and never knowing the field name. If your descriptor needs the attribute name for storage and you skip __set_name__, you'll either hard-code the name in the constructor (repetitive) or get a KeyError later when something looks up an attribute that was never set.

Recursion in `__set__`. Calling setattr(instance, self.name, value) from inside __set__ re-triggers the descriptor protocol. The fix is to write into instance.__dict__ directly, or use a different key (self.private_name).

Assuming descriptors fire on instance-level dict pokes. shirt.__dict__["price"] = 99 bypasses the descriptor entirely. For data descriptors this means a future shirt.price read goes through the descriptor and ignores the poked value, which can look like the assignment vanished. For non-data descriptors the poked value wins. Either way, accessing __dict__ directly is rarely what you want.

Quiz

Descriptors Quiz

10 quizzes