A descriptor in Python is an object that controls what happens when you access an attribute on another object. If a class attribute implements the descriptor protocol, Python calls the descriptor's methods instead of returning the attribute directly. This is the mechanism behind the property decorator, class methods, static methods, and many advanced customization patterns.
The article on customizing Python objects with magic methods covered attribute access hooks at the object level. Descriptors operate at the class level and give you reusable, shareable attribute behaviour that works across multiple classes.
Understanding descriptors unlocks the ability to build your own versions of property-like features, create ORM field definitions, and implement validation that works correctly with Python's lookup machinery.
The descriptor protocol
The descriptor protocol consists of three methods. The get method receives the instance and the owner class, the set method receives the instance and the new value, and the delete method receives the instance.
An object becomes a descriptor by implementing at least one of these methods. The most basic descriptor only implements get and returns a computed value.
class Constant:
def __init__(self, value):
self.value = value
def __get__(self, instance, owner):
return self.value
class Config:
max_connections = Constant(100)
timeout = Constant(30)
cfg = Config()
print(cfg.max_connections)
print(cfg.timeout)The Constant descriptor returns the same value regardless of which instance accesses it. The descriptor object is stored on the class, but Python calls its get method rather than returning the descriptor itself.
100
30The get method receives two arguments: instance is the object through which the attribute was accessed, and owner is the class where the descriptor was defined. If the attribute was accessed on the class itself rather than an instance, instance is None.
How data descriptors differ from non-data descriptors
A descriptor that implements both get and set (or get and delete) is called a data descriptor. A descriptor that implements only get is a non-data descriptor. The distinction matters because Python gives data descriptors priority over instance dictionaries during attribute lookup.
When you access obj.attr, Python first looks for a data descriptor named attr on the type, and if one exists, its get method is called. If no data descriptor is found, Python checks the instance dictionary. If the key is not there, Python looks for a non-data descriptor on the type.
This priority rule is why the property decorator works correctly. A property is a data descriptor. When you set obj.prop = value, the property's set method fires even if the instance dictionary already has a key named prop.
Here is a data descriptor that validates values before storing them. The descriptor keeps per-instance data in a dictionary keyed by the instance id.
class ValidatedAttribute:
def __init__(self, name, validator):
self.name = name
self.validator = validator
self.storage = {}
def __get__(self, instance, owner):
if instance is None:
return self
return self.storage.get(id(instance))
def __set__(self, instance, value):
if not self.validator(value):
raise ValueError(f"{self.name}: invalid value {value}")
self.storage[id(instance)] = valueWith the descriptor class defined, applying it to a class attribute is a single line. The descriptor handles all storage and validation behind the scenes.
def positive(value):
return value > 0
class Product:
price = ValidatedAttribute("price", positive)
p = Product()
p.price = 99
print(p.price)The price attribute is a data descriptor. Every access and assignment goes through the descriptor's get and set methods. The validation runs on every write.
99Notice the data storage uses a dictionary keyed by instance id. This is a common pattern because the descriptor object is shared across all instances of the class, so it cannot store per-instance data on itself.
Keying by id() has a real downside: entries are never removed, and once an instance is garbage collected, Python can reuse its id for a new object, which would then read stale data left behind by the old one. Production code should key a WeakKeyDictionary by the instance itself instead, so entries disappear automatically when the instance does.
Where descriptors appear in standard Python
You already use descriptors every day without realizing it. The property decorator creates a descriptor object behind the scenes. When you write an at-property method, Python builds a descriptor with get, set, and delete methods from your function definitions.
Functions themselves are descriptors. When you access a method on an instance, Python calls the function's get method, which returns a bound method object. This is why self is automatically passed as the first argument.
class Demo:
def method(self):
return "called"
d = Demo()
bound = d.method
print(bound())
print(Demo.method.__get__(d, Demo)())Both calls produce the same result. Accessing d.method calls the function descriptor's get method, which binds the instance to the function and returns a callable that fills in self automatically.
called
calledClass methods and static methods are also descriptors. The classmethod descriptor's get method passes the class instead of the instance as the first argument. The staticmethod descriptor's get method simply returns the original function unchanged.
Building a reusable typed attribute descriptor
The real power of descriptors is reusability. You write the validation logic once as a descriptor class and apply it to any attribute on any class. Here is a descriptor that enforces type constraints.
class Typed:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
self.storage = {}
def __get__(self, instance, owner):
if instance is None:
return self
return self.storage.get(id(instance))
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}"
)
self.storage[id(instance)] = valueNow you can declare typed fields on any class with minimal boilerplate. Each field knows its own name and expected type.
class Account:
holder = Typed("holder", str)
balance = Typed("balance", float)
a = Account()
a.holder = "Maya"
a.balance = 100.0
print(a.holder, a.balance)The descriptor validates every assignment. Assigning a string to balance would raise TypeError immediately, before the value is stored anywhere.
Maya 100.0This pattern is the foundation of how ORMs define database columns, how form libraries validate input, and how configuration systems enforce constraints. The article on Python properties vs descriptors compares this approach to the property decorator for common use cases.
Rune AI
Key Insights
- A descriptor implements get, and optionally set or delete, to control attribute access on another object.
- Data descriptors implement get and set/delete; they take priority over instance dictionaries.
- Non-data descriptors implement only get; instance attributes can override them.
- The property decorator, classmethod, and staticmethod are all descriptors provided by the standard library.
- Descriptors receive the instance and the owner class, giving them full context for each access.
Frequently Asked Questions
What is a descriptor in Python?
What is the difference between data and non-data descriptors?
Conclusion
Descriptors are the mechanism behind many Python features you use daily. Properties, class methods, static methods, and slots are all built on the descriptor protocol. Understanding descriptors gives you the power to create reusable attribute management that works correctly with the full Python attribute lookup system.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.