Python Descriptors: The Protocol Behind Properties
Learn how Python descriptors work under the hood, powering properties, class methods, and custom attribute access with __get__, __set__, and __delete__.
What you'll learn
- ✓How the descriptor protocol powers properties and methods
- ✓Implementing __get__, __set__, and __delete__
- ✓Building reusable validation descriptors
- ✓Understanding data vs non-data descriptors
Prerequisites
- •Solid understanding of Python classes
- •Familiarity with properties and decorators
- •Basic knowledge of dunder methods
What Are Descriptors?
A descriptor is any object that defines at least one of __get__, __set__, or __delete__. When such an object lives as a class attribute, Python intercepts attribute access and calls the descriptor method instead of returning the object directly. This mechanism is the engine behind property, classmethod, staticmethod, and even regular method binding.
Understanding descriptors gives you the ability to build reusable attribute-level logic that works across many classes without repeating code.
The Descriptor Protocol Methods
The three methods that make up the descriptor protocol are:
class Descriptor:
def __get__(self, obj, objtype=None):
"""Called when the attribute is accessed."""
pass
def __set__(self, obj, value):
"""Called when the attribute is assigned."""
pass
def __delete__(self, obj):
"""Called when the attribute is deleted."""
pass
The parameters deserve explanation:
selfis the descriptor instance itself.objis the instance of the class that owns the descriptor, orNoneif accessed from the class.objtypeis the class that owns the descriptor.
A Simple Descriptor Example
Let’s build a descriptor that logs every access and mutation:
class LoggedAttribute:
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = getattr(obj, self.private_name, None)
print(f"Accessing {self.name}: {value}")
return value
def __set__(self, obj, value):
print(f"Setting {self.name} to {value}")
setattr(obj, self.private_name, value)
def __delete__(self, obj):
print(f"Deleting {self.name}")
delattr(obj, self.private_name)
class User:
name = LoggedAttribute()
email = LoggedAttribute()
def __init__(self, name, email):
self.name = name
self.email = email
user = User("Alice", "alice@example.com")
# Output:
# Setting name to Alice
# Setting email to alice@example.com
print(user.name)
# Output:
# Accessing name: Alice
# Alice
Notice __set_name__, introduced in Python 3.6. Python calls this automatically when the class is created, passing the attribute name. This eliminates the need for the programmer to manually specify the name.
Data Descriptors vs Non-Data Descriptors
Python distinguishes between two kinds of descriptors, and the difference controls attribute lookup priority.
Data descriptors define __set__ or __delete__ (or both). They take priority over instance dictionaries. This means even if an instance has a key in its __dict__ with the same name, the data descriptor wins.
Non-data descriptors define only __get__. Instance dictionary entries take priority over non-data descriptors.
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
return "from descriptor"
class DataDescriptor:
def __get__(self, obj, objtype=None):
return "from descriptor"
def __set__(self, obj, value):
pass
class MyClass:
non_data = NonDataDescriptor()
data = DataDescriptor()
obj = MyClass()
# Non-data descriptor: instance dict can override it
obj.__dict__["non_data"] = "from instance"
print(obj.non_data) # "from instance"
# Data descriptor: instance dict cannot override it
obj.__dict__["data"] = "from instance"
print(obj.data) # "from descriptor"
This distinction explains why property (a data descriptor) always intercepts access, while regular methods (non-data descriptors) can be overridden per instance.
The Attribute Lookup Chain
When you access obj.x, Python follows this lookup order:
- Data descriptors from the class (and its MRO)
- Instance
__dict__ - Non-data descriptors and other class attributes
Understanding this chain is essential for debugging unexpected attribute behavior.
class Verbose:
def __get__(self, obj, objtype=None):
print(f"__get__ called, obj={obj}, objtype={objtype}")
if obj is None:
return self
return obj.__dict__.get("_value", "default")
def __set__(self, obj, value):
print(f"__set__ called with {value}")
obj.__dict__["_value"] = value
class Example:
attr = Verbose()
# Class-level access: obj is None
Example.attr
# __get__ called, obj=None, objtype=<class 'Example'>
e = Example()
e.attr = 42 # __set__ called with 42
print(e.attr) # __get__ called, prints 42
Building a Validation Descriptor
Descriptors shine when you need reusable validation logic:
class Validated:
def __init__(self, validator, error_msg=None):
self.validator = validator
self.error_msg = error_msg
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
if not self.validator(value):
msg = self.error_msg or f"Invalid value for {self.name}: {value}"
raise ValueError(msg)
setattr(obj, self.private_name, value)
# Reusable validators
def positive(value):
return isinstance(value, (int, float)) and value > 0
def non_empty_string(value):
return isinstance(value, str) and len(value.strip()) > 0
def in_range(low, high):
def check(value):
return isinstance(value, (int, float)) and low <= value <= high
return check
class Product:
name = Validated(non_empty_string, "Name must be a non-empty string")
price = Validated(positive, "Price must be positive")
rating = Validated(in_range(0, 5), "Rating must be between 0 and 5")
def __init__(self, name, price, rating):
self.name = name
self.price = price
self.rating = rating
laptop = Product("ThinkPad", 999.99, 4.5)
print(laptop.name) # ThinkPad
print(laptop.price) # 999.99
try:
Product("", 100, 3)
except ValueError as e:
print(e) # Name must be a non-empty string
try:
Product("Widget", -5, 3)
except ValueError as e:
print(e) # Price must be positive
Type-Checked Descriptor
A common pattern is a descriptor that enforces types:
class TypeChecked:
def __init__(self, expected_type):
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.private_name, value)
class Config:
host = TypeChecked(str)
port = TypeChecked(int)
debug = TypeChecked(bool)
def __init__(self, host, port, debug=False):
self.host = host
self.port = port
self.debug = debug
config = Config("localhost", 8080)
print(config.host) # localhost
try:
config.port = "not a number"
except TypeError as e:
print(e) # port must be int, got str
How property Uses Descriptors
The built-in property is itself a descriptor. Here is a simplified version showing how it works internally:
class MyProperty:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
class Circle:
def __init__(self, radius):
self._radius = radius
@MyProperty
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
c = Circle(5)
print(c.radius) # 5
c.radius = 10
print(c.radius) # 10
How Methods Use Descriptors
Regular functions are non-data descriptors. When you access a method through an instance, __get__ binds the function to the instance:
class Greeter:
def hello(self):
return f"Hello from {self}"
g = Greeter()
# Accessing through class returns the function
print(Greeter.__dict__["hello"]) # <function Greeter.hello at ...>
# Accessing through instance triggers __get__, returning a bound method
print(g.hello) # <bound method Greeter.hello of <Greeter object>>
# You can call __get__ manually to see the binding
func = Greeter.__dict__["hello"]
bound = func.__get__(g, Greeter)
print(bound()) # Hello from <Greeter object>
Caching Descriptor
A descriptor that computes a value once and caches it per instance:
class CachedProperty:
def __init__(self, func):
self.func = func
self.attrname = None
def __set_name__(self, owner, name):
self.attrname = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Check if already cached in instance dict
if self.attrname in obj.__dict__:
return obj.__dict__[self.attrname]
# Compute and cache
value = self.func(obj)
obj.__dict__[self.attrname] = value
return value
class DataProcessor:
def __init__(self, data):
self.data = data
@CachedProperty
def statistics(self):
print("Computing statistics...")
return {
"mean": sum(self.data) / len(self.data),
"min": min(self.data),
"max": max(self.data),
"count": len(self.data),
}
proc = DataProcessor([10, 20, 30, 40, 50])
print(proc.statistics) # Computing statistics... then prints dict
print(proc.statistics) # Returns cached value, no recomputation
This pattern is so useful that Python 3.8 added functools.cached_property which works the same way. Since CachedProperty only defines __get__, it is a non-data descriptor, which means once the value is stored in the instance __dict__, the instance dict entry takes priority and the descriptor is never called again.
Descriptor with Class-Level Storage
Sometimes you want a descriptor to maintain state at the class level, for example counting how many times an attribute is set across all instances:
class Counted:
def __init__(self):
self.count = 0
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
self.count += 1
setattr(obj, self.private_name, value)
class Order:
status = Counted()
def __init__(self, status="pending"):
self.status = status
o1 = Order("pending")
o2 = Order("shipped")
o1.status = "delivered"
print(Order.status.count) # 3 (set three times across all instances)
Common Pitfalls
Forgetting set_name: Without it, you must pass the attribute name manually or use some other mechanism. Always implement __set_name__ for clean APIs.
Storing data on the descriptor: If you store per-instance data on the descriptor itself (instead of on obj), all instances share the same data. Always use setattr(obj, ...) or obj.__dict__.
Infinite recursion: Inside __get__ or __set__, calling getattr(obj, self.name) or setattr(obj, self.name, ...) triggers the descriptor again. Use self.private_name or access obj.__dict__ directly.
# WRONG: causes infinite recursion
class Bad:
def __set__(self, obj, value):
setattr(obj, self.name, value) # Calls __set__ again!
# RIGHT: use a different name or __dict__
class Good:
def __set__(self, obj, value):
obj.__dict__[self.name] = value
Wrapping Up
Descriptors are one of Python’s most powerful and underappreciated features. They are the mechanism behind properties, methods, class methods, static methods, and many third-party libraries. By implementing __get__, __set__, and __delete__, you can build reusable attribute logic for validation, caching, type checking, and access control. The key distinction between data and non-data descriptors determines lookup priority, which explains many surprising behaviors in Python’s attribute system. Once you understand descriptors, the rest of Python’s object model falls into place.
Related articles
- Python Python Descriptors and Properties: The Complete Guide
Understand Python's descriptor protocol, how properties work internally, and how to build reusable validation descriptors.
- Python Python ABCs vs Protocols: Choosing the Right Abstraction
Understand the difference between Abstract Base Classes and Protocols in Python. Learn when nominal typing beats structural typing and vice versa.
- Python Python Design Patterns: Strategy, Factory, and Observer
Learn how to implement the Strategy, Factory, and Observer design patterns in Python using idiomatic constructs like first-class functions and protocols.
- Python Advanced Python Dataclasses: Beyond the Basics
Go beyond basic dataclass usage with post-init processing, field factories, inheritance, frozen instances, and custom serialization.