Python properties are useful when one class needs a managed attribute. When the same attribute behavior must be reused across many fields or classes, repeating nearly identical properties becomes harder to maintain.

Descriptors provide the protocol underneath properties, bound methods, classmethod, staticmethod, and other familiar Python features. A descriptor is an object stored on a class that can participate in reading, writing, or deleting an attribute.

Descriptors are powerful because they integrate with normal dotted access such as obj.width. They are also easy to misuse if you do not understand where descriptor instances live, how values should be stored, and which lookup rules Python applies.

The descriptor protocol is small

An object participates in the descriptor protocol by defining one or more of these methods:

__get__(self, obj, objtype=None)
__set__(self, obj, value)
__delete__(self, obj)

The descriptor itself is normally assigned as a class attribute. Python’s attribute machinery discovers it during dotted lookup and may call the appropriate protocol method.

A minimal read-only example looks like this:

class Constant:
    def __init__(self, value):
        self.value = value

    def __get__(self, obj, objtype=None):
        return self.value


class Configuration:
    protocol_version = Constant(3)


config = Configuration()
assert config.protocol_version == 3

protocol_version is stored on Configuration, but reading it through config invokes Constant.__get__().

For a constant, an ordinary class attribute would be simpler. The example is useful because it shows the central idea: the class attribute is an object that controls what attribute access returns.

Descriptors belong on the class

Putting a descriptor object into an instance does not activate descriptor behavior.

This distinction matters:

class Echo:
    def __get__(self, obj, objtype=None):
        return "managed"


class Example:
    field = Echo()


item = Example()
assert item.field == "managed"

item.other = Echo()
assert isinstance(item.other, Echo)

field is found through the class and participates in descriptor lookup. other is merely a normal value in the instance dictionary.

A descriptor therefore usually represents behavior shared by all instances of its owning class. Per-instance state should normally be stored on each instance, not inside the shared descriptor object.

Use __set_name__ to learn the attribute name

Reusable descriptors often need to know which class attribute they were assigned to. Python calls __set_name__(owner, name) during class creation when a class attribute defines that method.

That lets one descriptor class manage several fields without hard-coding their names:

class Positive:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, 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")
        setattr(obj, self.private_name, value)


class Rectangle:
    width = Positive()
    height = Positive()

    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

Assignment in Rectangle.__init__() calls Positive.__set__(). The validated values are stored as _width and _height on each Rectangle instance.

The descriptor objects themselves remain shared class attributes.

Handle class-level access deliberately

When Python evaluates Rectangle.width, it calls the descriptor’s __get__() with obj set to None and the class supplied separately.

Returning self in that case is a common and useful convention:

if obj is None:
    return self

It allows class-level code to inspect the descriptor:

assert Rectangle.width.public_name == "width"

A descriptor can choose different class-level behavior, but it should do so intentionally. Code that blindly tries to read instance storage when obj is None will fail on class access.

Data and non-data descriptors have different precedence

The most important descriptor distinction is whether the object is a data descriptor.

A descriptor defining __set__() or __delete__() is a data descriptor. A descriptor defining __get__() but neither of those methods is a non-data descriptor.

For normal instance lookup, the relevant precedence is:

data descriptor
instance dictionary
non-data descriptor
ordinary class attribute

This explains why a validating descriptor such as Positive cannot be bypassed merely by assigning the public name into an instance dictionary through ordinary attribute assignment. Its __set__() makes it a data descriptor, and data descriptors take precedence over same-named instance entries during lookup.

Non-data descriptors behave differently. An instance attribute with the same name can override them.

This distinction is not an implementation curiosity. Python functions are non-data descriptors, which is part of the mechanism that turns functions stored on classes into bound methods when accessed through instances.

A property is a descriptor

property is a data descriptor. That is why a property can manage an attribute while preserving normal syntax:

class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

For one or two class-specific attributes, a property is often clearer than defining a custom descriptor class.

Descriptors become more attractive when the behavior itself is reusable. Examples include:

  • validating several fields with the same rule;
  • converting assigned values into a canonical representation;
  • lazy or computed access patterns with carefully defined caching semantics;
  • framework fields that map attributes to external storage;
  • instrumentation that consistently observes attribute access.

Do not reach for a custom descriptor merely because it is possible. Prefer the simplest abstraction that keeps the behavior explicit.

Store instance data on the instance

A common descriptor bug is storing a managed value directly on the descriptor:

class BrokenField:
    def __set__(self, obj, value):
        self.value = value

    def __get__(self, obj, objtype=None):
        return self.value

Because the descriptor is shared by every instance, all instances now share one value slot.

first.field = 10
second.field = 20
first.field unexpectedly reads 20

For ordinary managed attributes, store the value on obj, as the Positive example does.

Another option is external per-instance storage, but that creates lifetime and ownership concerns. A normal dictionary keyed by instances keeps those instances alive. A weak-key mapping can avoid that ownership in appropriate designs, but it also adds complexity. Instance storage is usually the simplest choice when the object can hold the value itself.

Avoid accidental recursion

A descriptor can recursively call itself if it stores through the same public attribute that it manages.

This is wrong:

class RecursiveField:
    def __set_name__(self, owner, name):
        self.name = name

    def __set__(self, obj, value):
        setattr(obj, self.name, value)

If the descriptor manages amount, then setattr(obj, "amount", value) invokes the same descriptor again, leading to recursion.

Store under a distinct private name, use an appropriate external store, or otherwise choose a storage path that does not re-enter the public descriptor unintentionally.

The same caution applies to __get__(): calling getattr(obj, self.public_name) from the descriptor managing that public name simply starts the same lookup again.

Adding descriptors after class creation needs care

__set_name__() is automatically invoked as part of class creation. If you attach a descriptor to a class later, Python does not automatically replay that class-creation notification.

For example, this assignment alone does not initialize a descriptor that relies on __set_name__():

Rectangle.depth = Positive()

Code performing dynamic class modification must call the descriptor’s __set_name__() explicitly when its design requires that initialization.

Frameworks that generate classes or fields dynamically should centralize this behavior rather than relying on individual callers to remember it.

Overriding __getattribute__ can change the picture

Automatic descriptor invocation is implemented by Python’s attribute-access machinery, including object.__getattribute__() for ordinary instances.

A class that overrides __getattribute__() takes responsibility for attribute access. If that override does not delegate appropriately, it can bypass the normal descriptor behavior entirely.

That is one reason custom __getattribute__() implementations deserve caution. They affect much more than handling a missing attribute.

__getattr__() is different: it is a fallback used after normal attribute lookup fails. Do not treat __getattr__() and __getattribute__() as interchangeable hooks.

Inheritance usually reuses the descriptor object

A descriptor defined on a base class is found through the normal method resolution order and can manage attributes on subclass instances.

That is convenient when the descriptor is stateless apart from class-definition metadata. It can be surprising when a descriptor mutates internal state at runtime, because subclasses and their instances may all be interacting with the same inherited descriptor object.

Keep reusable descriptors focused on behavior and stable metadata. Put mutable per-instance state on the instance. If per-owner-class state is required, design that ownership explicitly instead of assuming every subclass receives an independent descriptor copy.

Type validation has Python-specific edge cases

The Positive example accepts int and float for readability, but production validation should define its domain precisely.

For example, bool is a subclass of int in Python, so:

isinstance(True, int)

is true.

If boolean values are invalid for a numeric field, reject them explicitly or use a validation rule that matches the application’s actual numeric model.

Similar decisions arise around decimal.Decimal, fractions.Fraction, NumPy scalar types, and user-defined numeric classes. A descriptor should enforce the contract the application needs rather than pretending that one universal definition of “number” exists.

Keep error behavior predictable

Descriptors participate in fundamental syntax, so surprising exceptions can make ordinary-looking code difficult to debug.

Validation failures should normally raise clear exceptions at assignment time. Missing underlying state should be considered separately.

Be particularly careful with AttributeError from __get__(). Attribute access machinery may interpret AttributeError as a missing attribute and involve __getattr__() if the class defines one. Do not use AttributeError as an arbitrary validation or application error when the attribute actually exists.

Test descriptors through normal attribute syntax

Calling descriptor.__get__() directly can be useful for a narrow unit test, but it does not exercise Python’s lookup precedence.

Tests should primarily use the syntax application code uses:

rectangle = Rectangle(3, 4)
assert rectangle.width == 3
assert rectangle.area() == 12

try:
    rectangle.width = 0
except ValueError:
    pass
else:
    raise AssertionError("zero width should be rejected")

Also test class-level access if __get__() supports it, inheritance when the descriptor is inherited, and interactions with instance storage when lookup precedence matters.

Common pitfalls

Reusing one descriptor instance under multiple names

A descriptor whose __set_name__() stores one public_name and private_name should normally have a separate descriptor instance for each managed attribute. Assigning the exact same descriptor object to two names can cause the later name notification to overwrite metadata used by the earlier name.

Hiding expensive work behind cheap-looking access

obj.attribute looks inexpensive. A descriptor that performs network I/O or a large database query can violate that expectation and make performance behavior difficult to see. Frameworks sometimes make this trade-off deliberately, but application code should consider whether an explicit method communicates expensive work better.

Using descriptors for class-specific logic

If behavior is needed in only one class and one field, a property is usually easier to read. Descriptors earn their complexity when the managed behavior is meaningfully reusable.

Forgetting class access

A __get__() implementation that assumes obj is always an instance can break introspection and ordinary class-level access. Decide what Class.attribute should mean.

Confusing descriptors with instance hooks

Descriptors are discovered as class attributes. Assigning a descriptor object to one instance does not make that instance attribute managed.

Use descriptors to express a reusable attribute contract

Descriptors are best understood as part of Python’s object model rather than as metaprogramming magic. A class stores a descriptor object, normal attribute access discovers it, and a small protocol determines how that object participates in reads and writes.

For most application code, properties and ordinary methods remain the clearer choices. When multiple attributes need the same managed behavior, however, a well-designed descriptor can centralize validation or access rules without changing the syntax callers use.

Keep per-instance values on the instance, understand data-versus-non-data lookup precedence, handle class access deliberately, and use __set_name__() for stable field metadata. Those rules cover most of the difference between a descriptor that is a useful abstraction and one that makes attribute behavior unnecessarily mysterious.