Skip to content
Codeloom
Python

Python Descriptors and Properties: The Complete Guide

Understand Python's descriptor protocol, how properties work internally, and how to build reusable validation descriptors.

·6 min read · By Codeloom
Advanced 13 min read

What you'll learn

  • How the descriptor protocol powers attribute access
  • Why property is a descriptor under the hood
  • Building reusable data descriptors for validation
  • The difference between data and non-data descriptors

Prerequisites

  • Python OOP fundamentals
  • Understanding of dunder methods
  • Familiarity with class attributes

What Is a Descriptor?

A descriptor is any object that defines __get__, __set__, or __delete__. When such an object is assigned as a class attribute, Python intercepts attribute access on instances and calls the descriptor methods instead of performing normal attribute lookup.

Descriptors are the mechanism behind property, classmethod, staticmethod, and even regular method binding. They are foundational to how Python works.

The Descriptor Protocol

The protocol consists of three optional methods:

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

When you access instance.attr, Python checks if attr on the class is a descriptor. If it has __get__, Python calls Descriptor.__get__(instance, type(instance)) instead of returning the descriptor object itself.

Data vs Non-Data Descriptors

This distinction is critical for understanding attribute lookup order:

  • Data descriptor: Implements __get__ AND __set__ (or __delete__). Takes priority over instance __dict__.
  • Non-data descriptor: Implements only __get__. Instance __dict__ takes priority.
class DataDescriptor:
    """Has both __get__ and __set__ -- takes priority over instance dict."""
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get('_value', 'default')

    def __set__(self, obj, value):
        obj.__dict__['_value'] = value

class NonDataDescriptor:
    """Has only __get__ -- instance dict takes priority."""
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return "from descriptor"

class MyClass:
    data = DataDescriptor()
    non_data = NonDataDescriptor()

obj = MyClass()
obj.__dict__['data'] = "from dict"
obj.__dict__['non_data'] = "from dict"

print(obj.data)      # "default" -- data descriptor wins over __dict__
print(obj.non_data)  # "from dict" -- __dict__ wins over non-data descriptor

How property Actually Works

The built-in property is just a data descriptor implemented in C. Here is a pure Python equivalent:

class Property:
    """Pure Python implementation of the property descriptor."""

    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 getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

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

When you write @property, you create a Property instance with fget set. When you add @name.setter, you create a new Property with fset also set.

Building Reusable Validation Descriptors

The real power of descriptors is reusability. Instead of writing @property with validation in every class, you write the descriptor once:

class Validated:
    """Base class for validated descriptors."""

    def __set_name__(self, owner, name):
        self.public_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.validate(value)
        setattr(obj, self.private_name, value)

    def validate(self, value):
        raise NotImplementedError

class PositiveNumber(Validated):
    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.public_name} must be a number")
        if value <= 0:
            raise ValueError(f"{self.public_name} must be positive")

class NonEmptyString(Validated):
    def __init__(self, max_length=None):
        self.max_length = max_length

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.public_name} must be a string")
        if not value.strip():
            raise ValueError(f"{self.public_name} cannot be empty")
        if self.max_length and len(value) > self.max_length:
            raise ValueError(
                f"{self.public_name} cannot exceed {self.max_length} chars"
            )

class Product:
    name = NonEmptyString(max_length=100)
    price = PositiveNumber()
    quantity = PositiveNumber()

    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price
        self.quantity = quantity

p = Product("Widget", 9.99, 100)   # Works
# Product("", 9.99, 100)           # ValueError: name cannot be empty
# Product("Widget", -1, 100)       # ValueError: price must be positive

Notice __set_name__. Introduced in Python 3.6, this method is called automatically when a descriptor is assigned to a class attribute. It receives the attribute name, eliminating the need to pass it manually.

Descriptors for Lazy Computation

A non-data descriptor can implement lazy attribute computation that caches itself in the instance dict:

class LazyProperty:
    """Compute a value once, then cache it on the instance."""

    def __init__(self, func):
        self.func = func
        self.__doc__ = func.__doc__

    def __set_name__(self, owner, name):
        self.attr_name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = self.func(obj)
        # Store in instance __dict__ so this descriptor
        # is never called again for this instance
        setattr(obj, self.attr_name, value)
        return value

class DataProcessor:
    def __init__(self, raw_data):
        self.raw_data = raw_data

    @LazyProperty
    def parsed(self):
        """Expensive parsing, only done once."""
        print("Parsing data...")
        return [int(x) for x in self.raw_data.split(",")]

    @LazyProperty
    def summary(self):
        """Compute summary statistics."""
        print("Computing summary...")
        data = self.parsed
        return {"min": min(data), "max": max(data), "mean": sum(data) / len(data)}

dp = DataProcessor("1,2,3,4,5")
print(dp.parsed)   # Prints "Parsing data..." then [1, 2, 3, 4, 5]
print(dp.parsed)   # No parsing message -- cached in instance __dict__

This works because LazyProperty is a non-data descriptor (no __set__), so once the value is stored in the instance __dict__, the instance attribute takes priority.

The set_name Hook

This small but important method makes descriptors much more ergonomic:

class TypeChecked:
    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        self.name = name
        self.private = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private, 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, 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

Wrapping Up

Descriptors are the hidden machinery behind many Python features. Properties, methods, class methods, and static methods are all descriptors. By understanding the protocol and the data vs non-data distinction, you can build powerful, reusable attribute management tools that keep your classes clean and your validation logic centralized.