Python Descriptors: Properties, Methods, and Slots
A descriptor is an object stored on a class that controls what happens when an attribute is read, assigned, or deleted. It does this by defining one or more of __get__, __set__, and __delete__.
The descriptor protocol is Python’s general mechanism for making an apparently simple expression such as obj.value run custom behavior. It powers familiar features including properties, bound methods, and attributes created by __slots__.
The problem descriptors solve
Without descriptors, an attribute would usually mean “look in the instance’s dictionary,” the mapping where an object stores per-instance values. A class could provide defaults, but it would be awkward to make one attribute validate input, compute a value on demand, or bind a function to a particular instance.
Descriptors move that behavior into an object assigned on the class:
class Positive:
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get("value", 0)
def __set__(self, instance, value):
if value < 0:
raise ValueError("value must be positive")
instance.__dict__["value"] = value
class Counter:
value = Positive()
Counter.value is not a number. It is a Positive descriptor. But counter.value can behave like a normal attribute while the descriptor validates and retrieves its value.
What happens during lookup
For an expression such as obj.name, Python’s normal attribute machinery examines the class and the instance. The details are implemented by object.__getattribute__, unless a class overrides __getattribute__, but the useful model is:
- Find
nameon the type ofobjor one of its base classes. - If the found class attribute is a data descriptor—one defining
__set__or__delete__—call its__get__first. - Otherwise, check
obj.__dict__for an instance value. - If no instance value exists, call the class attribute’s
__get__if it has one. - Otherwise, return the class attribute itself.
A descriptor’s read hook has this signature:
descriptor.__get__(instance, owner)
instance is the object being accessed, and owner is normally its type. When the attribute is accessed through the class, as in Counter.value, Python calls __get__(None, Counter). Descriptors commonly return themselves in that case so class-level introspection remains possible.
The precedence rule is important. A data descriptor wins over an instance dictionary entry; this is what lets a property prevent callers from silently replacing it. A descriptor that defines only __get__ is a non-data descriptor. An instance value takes precedence over such a descriptor.
The short version:
__set__or__delete__makes a descriptor authoritative;__get__alone supplies behavior only when the instance has not provided an overriding value.
Assignment and deletion
For obj.name = value, Python looks for a data descriptor named name on the class. If one exists, it calls:
descriptor.__set__(obj, value)
The descriptor decides whether to accept the value and where to store it. If no data descriptor handles the assignment, Python normally writes name into the instance dictionary.
For del obj.name, a descriptor defining __delete__ receives:
descriptor.__delete__(obj)
It can remove backing state, reject deletion, or perform another operation. If no descriptor handles deletion, Python removes the name from the instance dictionary. Missing attributes generally produce AttributeError.
A descriptor may support only the operations it needs. For example, a read-only descriptor can implement __get__ but omit __set__; assignment then follows normal rules unless the descriptor is made data-descriptor-like by defining a setter that raises AttributeError.
Properties are descriptors
The built-in property type packages this pattern. A property stores functions for reading, writing, and deleting an attribute:
class User:
def __init__(self, email):
self._email = email
@property
def email(self):
return self._email.lower()
@email.setter
def email(self, value):
if "@" not in value:
raise ValueError("invalid email")
self._email = value
User.email is a property object on the class. user.email calls its getter through __get__; user.email = value calls its setter through __set__. A property is a data descriptor, so an instance cannot bypass it by placing another email value in its dictionary.
Bound methods are descriptors too
Functions defined in a class also implement __get__. When accessed through an instance, a function’s descriptor behavior creates a bound method: a callable that remembers the instance and supplies it as the first argument.
class Worker:
def run(self, job):
return f"running {job}"
worker = Worker()
worker.run("backup")
Conceptually, worker.run is produced by something like Worker.__dict__["run"].__get__(worker, Worker). Calling it then passes worker as self. Accessing Worker.run instead calls the function descriptor with instance=None, leaving an ordinary function that expects an explicit instance.
This is why methods do not need special syntax in the class body to become instance-aware: ordinary functions already participate in the descriptor protocol.
Slots use descriptors for storage
When a class declares __slots__, Python creates descriptor objects for the named attributes:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
The class receives slot descriptors—often visible in introspection as member_descriptor objects. Their __get__, __set__, and __delete__ implementations read and write fixed storage associated with each object rather than entries in __dict__. This can reduce per-instance memory use and restrict instances to the declared attributes.
Consequently, point.x = 3 invokes the slot descriptor, while point.z = 4 raises AttributeError unless the class also provides a __dict__ slot. The same lookup rules used for properties and methods are therefore also the machinery behind compact, slot-based instance attributes.