Skip to content
Codeloom
Python

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__.

·9 min read · By Codeloom
Advanced 14 min read

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:

  • self is the descriptor instance itself.
  • obj is the instance of the class that owns the descriptor, or None if accessed from the class.
  • objtype is 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:

  1. Data descriptors from the class (and its MRO)
  2. Instance __dict__
  3. 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.