Python Descriptors: Practical Guide

Updated on: July 22, 2026
Reading time: 6 minutes
Python code demonstrating descriptors and attributes

Python descriptors are one of the mechanisms behind properties, methods, validation libraries, object-relational mappers, and many framework features. They may look mysterious at first, but the core idea is straightforward: an object stored on a class can control what happens when another attribute is read, assigned, or deleted. In this guide, you will learn the descriptor protocol, build reusable examples with __get__, __set__, __delete__, and __set_name__, and understand when descriptors are better than simpler alternatives.

Descriptors are an advanced object-oriented feature, so it helps to be comfortable with functions, classes, and attribute lookup. You can also review related topics such as Python variable scope, *args and **kwargs, and the return statement before studying the examples below.

What is a Python descriptor?

A descriptor is an object that defines at least one of the special methods __get__, __set__, or __delete__. When that object is assigned as a class attribute, Python invokes those methods automatically during attribute access. This lets a class centralize logic such as validation, conversion, logging, lazy loading, access control, or computed values.

The behavior is part of Python’s official data model. The descriptor section of the data model explains the exact lookup rules, while the official Descriptor HowTo Guide shows how functions, methods, properties, and other language features use the same protocol.

A first descriptor with __get__

The simplest descriptor can define only __get__. The following example returns a greeting when accessed through an instance and returns the descriptor itself when accessed through the class.

class Greeting:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return f"Hello, {instance.name}!"


class User:
    message = Greeting()

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


user = User("Alex")
print(user.message)
print(User.message)

The instance argument is the object that requested the attribute. It is None when the attribute is accessed directly from the class. The owner argument is the class that owns the descriptor. Returning self for class access is a useful convention because it supports inspection and configuration.

Validating assignments with __set__

Validation is one of the most practical descriptor use cases. The next class accepts only non-negative integers and stores the value on each instance.

class PositiveInteger:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError(f"{self.public_name} must be an integer")
        if value < 0:
            raise ValueError(f"{self.public_name} cannot be negative")
        setattr(instance, self.private_name, value)


class Person:
    age = PositiveInteger()

    def __init__(self, age):
        self.age = age

The __set_name__ method runs when Python creates the owner class. It tells the descriptor which attribute name it received. This makes one descriptor class reusable for many fields without passing every name manually.

Data and non-data descriptors

Descriptors are usually divided into two groups. A data descriptor implements __set__ or __delete__, and may also implement __get__. A non-data descriptor implements only __get__. The distinction matters because Python gives them different priority during attribute lookup.

  • Data descriptors take priority over values stored in an instance dictionary.
  • Non-data descriptors can be shadowed by an instance attribute with the same name.
  • Normal methods are non-data descriptors.
  • property objects are data descriptors.

This priority explains why assigning directly to instance.__dict__ does not always change the value returned by an attribute. It also connects descriptors to method binding, inheritance, and the method resolution order.

How property uses descriptors

The familiar @property decorator creates a descriptor object. That object stores getter, setter, and deleter functions and calls them when the attribute is used. In many classes, a property is the clearest solution because the behavior belongs to one specific field.

class Product:
    def __init__(self, price):
        self.price = price

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = float(value)

Choose a property when the rule is local to one class. Choose a custom descriptor when the same behavior must be reused across multiple fields or classes. This distinction prevents unnecessary abstraction.

A reusable text descriptor

The following descriptor validates required text, trims whitespace, and enforces a maximum length. It can be used for many attributes without repeating validation code.

class RequiredText:
    def __init__(self, max_length=100):
        self.max_length = max_length

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

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name} must be text")

        value = value.strip()
        if not value:
            raise ValueError(f"{self.name} is required")
        if len(value) > self.max_length:
            raise ValueError(
                f"{self.name} exceeds {self.max_length} characters"
            )

        setattr(instance, self.storage_name, value)


class Article:
    title = RequiredText(80)
    author = RequiredText(50)

    def __init__(self, title, author):
        self.title = title
        self.author = author

This approach keeps domain classes compact and makes validation consistent. Error messages should still be specific because a generic failure inside automatic attribute access can be difficult to debug.

Store values on the instance

A common mistake is storing the current value on the descriptor itself, for example with self.value. A descriptor object belongs to the class and is shared by every instance, so that design causes unrelated objects to share data. Values normally belong in the instance, often under a private storage name.

Another option is weakref.WeakKeyDictionary, which maps instances to values without keeping the instances alive. It can help when the owner object cannot store attributes, but it introduces extra complexity and should not be the default choice.

Avoiding infinite recursion

Inside __set__, assigning to the public attribute again triggers the descriptor recursively. For example, instance.age = value inside the age descriptor calls __set__ forever. Use a separate storage name such as _age, write directly to instance.__dict__, or use another safe storage strategy.

The same warning applies to __get__. Reading the public name from inside its own descriptor repeats the lookup. A clear naming convention is the easiest protection.

Descriptors and inheritance

Descriptors participate in normal class inheritance. A subclass can inherit the descriptor, replace it, or expose another attribute with the same name. Data descriptors usually keep priority over instance values, while non-data descriptors can be shadowed.

Test subclass behavior explicitly, especially when a framework uses descriptors to declare fields. Method resolution order, metaclasses, and generated attributes can change when __set_name__ runs or which owner class is passed to __get__.

Descriptors and dataclasses

Descriptors can be combined with dataclasses, but defaults and initialization order require care. A dataclass may treat a descriptor as a field default, while the descriptor expects to manage assignments itself. In simpler models, field, validators in __post_init__, or a regular property may be easier to maintain.

Use a descriptor when the same field behavior appears repeatedly. Use dataclass validation when a rule depends on several fields together. This keeps responsibilities clear.

Testing descriptor behavior

Tests should cover valid assignments, invalid types, invalid ranges, class-level access, deletion when supported, and independence between instances.

import pytest


def test_values_are_independent():
    first = Person(20)
    second = Person(40)
    assert first.age == 20
    assert second.age == 40


def test_negative_age_is_rejected():
    with pytest.raises(ValueError):
        Person(-1)

Performance-sensitive descriptors should also be measured because every attribute access adds Python-level logic. The article about speeding up Python with lru_cache introduces another form of reusable runtime behavior and shows why measurement matters before optimization.

When descriptors are useful

  • Reusable validation across multiple classes.
  • Automatic conversion and normalization.
  • Lazy or cached attribute computation.
  • Database field mapping in ORMs.
  • Audit logging for reads and writes.
  • Framework and library design.

Descriptors are less attractive when a function, constructor check, or property solves the problem clearly. Automatic behavior is powerful, but it can surprise readers who do not know that attribute access is executing custom code.

Common mistakes

  • Storing values on the shared descriptor object.
  • Using the public name internally and causing recursion.
  • Ignoring class-level access where instance is None.
  • Building a complex descriptor for a rule used only once.
  • Skipping tests for inheritance, serialization, and introspection.
  • Hiding expensive work behind an ordinary-looking attribute.

Conclusion

Python descriptors provide reusable control over attribute access and explain how methods, properties, and many framework fields work. The protocol uses only a few special methods, but correct design requires careful storage, predictable errors, and a clear understanding of lookup priority.

Start with a small descriptor, test it with multiple instances, and compare it with a property or normal validation function. When the same behavior truly repeats across classes, a well-designed descriptor can remove duplication and make rules more consistent without making the public API harder to use.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Caixas empilhadas
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Modules and Packages: Complete Guide

    Learn how to create Python modules and packages, organize imports, use __init__.py, run modules, avoid circular imports, and structure real

    Ler mais

    Tempo de leitura: 5 minutos
    10/07/2026
    logo do python com objetos abaixo do logo
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Object-Oriented Python: Classes and Objects

    Learn object-oriented Python with classes, objects, attributes, methods, constructors, inheritance, composition, encapsulation, properties, and practical examples.

    Ler mais

    Tempo de leitura: 6 minutos
    10/07/2026
    Programação assíncrona com asyncio em Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python asyncio: A Practical Beginner’s Guide

    Learn Python asyncio with coroutines, await, tasks, TaskGroup, timeouts, cancellation, queues, locks, error handling, and blocking work.

    Ler mais

    Tempo de leitura: 5 minutos
    10/07/2026
    Uso de expressões regulares regex para manipulação de texto em Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Regex: Complete Regular Expressions Guide

    Learn Python regex with re.search, fullmatch, findall, groups, quantifiers, substitutions, flags, compiled patterns, and practical examples.

    Ler mais

    Tempo de leitura: 7 minutos
    10/07/2026
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Decorators Explained: Practical Guide

    Learn Python decorators with practical examples, wrappers, arguments, functools.wraps, class decorators, common use cases, and mistakes to avoid.

    Ler mais

    Tempo de leitura: 8 minutos
    21/05/2026
    Uso do super em Python para resolver problemas de herança
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Use super() in Python and Fix Inheritance Errors

    Learn how to use super() in Python for parent methods, multiple inheritance, MRO, *args, **kwargs, and cleaner object-oriented code.

    Ler mais

    Tempo de leitura: 8 minutos
    19/05/2026