The Descriptor Protocol

· 5 min read · Updated March 13, 2026 · advanced
python descriptors metaprogramming classes oop

If you have used Python for any length of time, you have already used descriptors—even if you did not realize it. Every time you access an attribute on a class, Python’s descriptor protocol is at work behind the scenes. Understanding this protocol opens up a powerful way to write reusable, expressive code.

This guide will show you how descriptors work, when to use them, and how to avoid common pitfalls.

What Is a Descriptor?

A descriptor is any object that defines one or more of these special methods:

  • __get__(self, obj, objtype=None) — controls attribute access (read)
  • __set__(self, obj, value) — controls attribute assignment (write)
  • __delete__(self, obj) — controls attribute deletion
  • __set_name__(self, owner, name) — called when the attribute is assigned to a class

When you access an attribute on a class, Python does not just look up the value in a dictionary. Instead, it looks for a descriptor object and, if found, calls the appropriate method.

Here is a simple example:

class Uppercase:
    def __get__(self, obj, objtype=None):
        return obj._value.upper()
    
    def __set__(self, obj, value):
        obj._value = value

class Person:
    name = Uppercase()
    
    def __init__(self, name):
        self.name = name  # This calls __set__

person = Person("alice")
print(person.name)  # ALICE — __get__ was called

In this example, every time you read or write person.name, Python calls the methods on the Uppercase descriptor rather than storing the value directly.

Data Descriptors vs Non-Data Descriptors

Descriptors split into two categories, and the distinction matters:

Data descriptors define __set__ or __delete__. They always take precedence over instance dictionary lookups.

Non-data descriptors only define __get__. Methods and classmethods are non-data descriptors—Python looks them up once when the class is created, then reuses the same bound method object.

This is why assigning to an instance attribute can shadow a method:

class MyClass:
    def method(self):
        return "original"

obj = MyClass()
obj.method = lambda: "shadowed"  # Shadows the method!
print(obj.method())  # Error: lambda object is not callable

If method were a data descriptor, this shadowing would not work—Python would always call __get__.

The set_name Method

Introduced in Python 3.6, __set_name__ lets descriptors know what name they were assigned to:

class Field:
    def __set_name__(self, owner, name):
        self.name = name
    
    def __get__(self, obj, objtype=None):
        return obj.__dict__.get(self.name)
    
    def __set__(self, obj, value):
        obj.__dict__[self.name] = value

class User:
    username = Field()
    email = Field()

When the class is created, Python calls Field.__set_name__(User, 'username') and Field.__set_name__(User, 'email'). The descriptor now knows its own name without hardcoding it.

Practical Example: Validated Attributes

Descriptors shine when you need reusable validation logic across multiple attributes or classes:

class Range:
    def __init__(self, min_val, max_val):
        self.min_val = min_val
        self.max_val = max_val
    
    def __set_name__(self, owner, name):
        self.name = name
    
    def __get__(self, obj, objtype=None):
        return obj.__dict__.get(self.name)
    
    def __set__(self, obj, value):
        if not (self.min_val <= value <= self.max_val):
            raise ValueError(
                f"{self.name} must be between {self.min_val} "
                f"and {self.max_val}, got {value}"
            )
        obj.__dict__[self.name] = value

class Player:
    health = Range(0, 100)
    level = Range(1, 99)
    
    def __init__(self, health, level):
        self.health = health
        self.level = level

player = Player(health=50, level=10)
player.health = 200  # Raises ValueError

This pattern gives you validation that works like a built-in attribute—no getter/setter boilerplate, just clean Python.

Common Built-in Descriptors

You have already used descriptors many times without knowing it:

property — the most common data descriptor:

class Circle:
    def __init__(self, radius):
        self.radius = radius
    
    @property
    def diameter(self):
        return self.radius * 2

classmethod — a non-data descriptor that binds to the class:

class Config:
    _settings = {}
    
    @classmethod
    def load(cls, path):
        # load from path into cls._settings
        pass

staticmethod — also a non-data descriptor:

class Math:
    @staticmethod
    def add(a, b):
        return a + b

When you use @property, @classmethod, or @staticmethod, you are defining descriptor objects that Python uses to manage attribute access.

Using delete

The __delete__ method lets you control what happens when someone tries to delete an attribute:

class Immutable:
    def __init__(self, default):
        self.default = default
    
    def __get__(self, obj, objtype=None):
        value = obj.__dict__.get(self.name)
        if value is None:
            return self.default
        return value
    
    def __set__(self, obj, value):
        if self.name in obj.__dict__:
            raise AttributeError(f"Cannot modify {self.name} after creation")
        obj.__dict__[self.name] = value

class Config:
    debug = Immutable(False)
    
    def __init__(self, debug=False):
        self.debug = debug

config = Config(True)
config.debug = False  # Works
del config.debug  # Raises: uses __delete__ if defined

Common Gotchas

Storing values: Descriptors should store values in the instance’s __dict__, not on the descriptor itself (unless you intentionally want shared state). This is what makes each instance have its own value:

class GoodDescriptor:
    def __get__(self, obj, objtype=None):
        return obj.__dict__.get(self.name)
    
    def __set__(self, obj, value):
        obj.__dict__[self.name] = value  # Good: instance storage

Using cls vs self: In __get__, obj is the instance (or None if accessed on the class). Use objtype when you need the class:

def __get__(self, obj, objtype=None):
    if obj is None:
        return self  # Accessed on class, not instance
    return obj.__dict__.get(self.name)

Descriptor ordering: When multiple descriptors are on the same class, Python calls them in definition order during attribute access.

When to Use Descriptors

Descriptors are the right tool when:

  • You need reusable property logic across multiple attributes
  • You want validation that feels like a native attribute
  • You are building a framework that needs to manage attribute access
  • You need lazy evaluation of expensive properties

For simple cases, consider whether a property decorator or regular method would be clearer. Descriptors add complexity—use them when the complexity pays off.

Conclusion

The descriptor protocol is one of Python’s most powerful but underused features. Key takeaways:

  1. Descriptors are objects with __get__, __set__, __delete__, or __set_name__
  2. Data descriptors always take precedence over instance dictionary lookups
  3. Use __set_name__ to avoid hardcoding attribute names
  4. Built-in decorators like @property are descriptors in disguise

Once you understand descriptors, you will see them everywhere in Python—and you will have a powerful tool for writing expressive, reusable code.

See Also

  • property() — The built-in property decorator, a data descriptor
  • __slots__ — Using slots for memory efficiency in Python classes
  • python-decorators — Python decorators explained, which use descriptor concepts
  • python-metaclasses — Understanding metaclasses, the next level of Python metaprogramming