Python Descriptors and Method Binding
A descriptor is an object that controls what happens when an attribute is read, written, or deleted. It does this by defining methods such as __get__ and __set__ on its class.
The descriptor protocol is Python’s general mechanism for turning an ordinary-looking expression such as account.balance into customizable behavior. It powers properties, automatic method binding, class methods, and much of the field machinery in frameworks and libraries.
The problem descriptors solve
Without descriptors, a class attribute would mostly be stored data, and a function stored on a class would remain just a function when accessed through an instance. Code would need explicit getter and setter calls:
account.get_balance()
account.set_balance(100)
There would also be no automatic step that supplies an instance as the first argument to a method. Each caller would have to write it manually.
Descriptors let a class author attach behavior to one attribute while preserving normal attribute syntax. A read can calculate or validate a value; a write can reject invalid data; a function can become a method bound to the object being used.
What happens on an attribute read
For an expression such as obj.name, Python normally asks the object’s attribute machinery to find name. The important lookup order is:
- Look on the class and its base classes for a data descriptor—a descriptor defining
__set__or__delete__. If found, call its__get__. - Look in the instance’s own dictionary, usually
obj.__dict__. - Look for a class attribute or a non-data descriptor, which defines
__get__but not__set__or__delete__. - If the class attribute is a descriptor, call
__get__; otherwise return the attribute itself.
The ordering matters. A data descriptor takes priority over an instance attribute with the same name. A non-data descriptor can be overridden by putting a value of the same name in the instance dictionary.
A descriptor’s read method has this shape:
class Descriptor:
def __get__(self, instance, owner=None):
...
instance is the object through which the attribute was read, and owner is normally its class. When the attribute is read on the class itself—Account.balance rather than account.balance—instance is None. Descriptors commonly return themselves in that case, allowing introspection or further configuration.
Here is a small data descriptor that validates a balance and stores it under a private name:
class Positive:
def __set_name__(self, owner, name):
self.storage_name = "_" + name
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__.get(self.storage_name, 0)
def __set__(self, instance, value):
if value < 0:
raise ValueError("balance cannot be negative")
instance.__dict__[self.storage_name] = value
class Account:
balance = Positive()
account.balance calls Positive.__get__. account.balance = 50 calls Positive.__set__ instead of simply adding balance to account.__dict__. Account.balance calls __get__ with instance=None, so it returns the descriptor object.
__set_name__ is a related class-creation hook: Python calls it when the descriptor is assigned to a class, giving it the attribute’s name. It is not required for the protocol, but it is convenient for descriptors that need private storage names.
What happens on an attribute write
For obj.name = value, Python’s normal assignment machinery first checks whether the class provides a data descriptor named name. If it does, Python calls:
descriptor.__set__(obj, value)
The descriptor decides whether to accept, transform, validate, or reject the value. A descriptor can also define __delete__ for del obj.name.
If no data descriptor handles the assignment, Python usually stores the value in the instance dictionary. This is why a non-data descriptor can be shadowed:
class Label:
def __get__(self, instance, owner=None):
return "default"
class Item:
label = Label()
item = Item()
item.label = "custom" # stored on item; Label has no __set__
A class can override __getattribute__ or __setattr__ and participate in attribute access too. Those methods sit outside the descriptor itself and can change or bypass the usual behavior. Directly reading or writing obj.__dict__ also bypasses descriptor dispatch.
Method binding is descriptor behavior
Functions defined in a class are descriptors. Their __get__ method creates a bound method when accessed through an instance:
class Greeter:
def greet(self, name):
return f"Hello, {name}"
greeter = Greeter()
greeter.greet("Ada")
Conceptually, greeter.greet is produced by binding Greeter.greet to greeter. The call is therefore similar to:
Greeter.greet(greeter, "Ada")
When accessed as Greeter.greet, the function is not bound to an instance, so it can be called only if the caller supplies the expected first argument. The familiar error about a missing self is often a sign that a function was accessed on the class rather than on an instance.
classmethod and staticmethod are also descriptor-based wrappers. A class method binds the class as the first argument, even when called through an instance. A static method performs no automatic binding and returns the underlying function without adding either an instance or a class.
Properties and framework machinery
A property is a built-in data descriptor. Its getter runs on a read, its optional setter runs on a write, and a property without a setter raises an error when code tries to assign to it:
class User:
@property
def email(self):
return self._email
@email.setter
def email(self, value):
self._email = value.strip().lower()
The code using user.email does not need to know that a method runs underneath.
The same pattern appears in ORM model fields, configuration objects, validation libraries, lazy-loading attributes, and dependency-injection frameworks. A class-level field declaration can be a descriptor that turns later reads into database lookups, converts values, records changes, or checks types.
When a stack trace mentions a read-only property, an unexpected bound method, a missing self, or an assignment that triggers validation instead of changing __dict__, descriptor dispatch is usually the mechanism behind it.