Python Descriptors Explained: From __get__ to @property

Python Descriptors Explained: From __get__ to @property — cover image

Python Descriptors Explained: The Machinery Behind @property

Key Takeaways

  • Using __get__(), __set__(), and __delete__(), descriptors manage attribute access.
  • @property allows attribute-style access with bespoke logic because it is based on descriptors.
  • When instance attributes are involved, various lookup rules apply to data and non-data descriptors.
  • Functions, staticmethod, and classmethod use descriptor machinery to control how attributes behave.
  • Custom descriptors enable reusable validation, lazy computation, and ORM-style fields, but should be used only when simpler tools are not enough.

Introduction

In reality, when Python evaluates:

user.age

It appears to be a concise dictionary search. The attributes of the instance are frequently consulted by Python, but that's not the whole picture.

Suppose age is defined on the class as a descriptor. Python can intercept that attribute access and execute descriptor logic instead of simply returning a stored value.

That mechanism is behind something you already use:

@property

But property is only one application of descriptors. The same protocol helps explain bound methods, classmethod, staticmethod, super(), validation patterns, and parts of Python's object model.

If you've already studied Dunder Methods, descriptors are the next useful step: instead of customizing operators such as + or ==, descriptor methods customize attribute access.

What Is a Descriptor in Python?

What Is a Descriptor in Python?

A descriptor is an object that defines at least one of these methods:

__get__()
__set__()
__delete__()

These methods form the descriptor protocol.

A minimal descriptor looks like this:

class Ten:
    def __get__(self, obj, objtype=None):
        return 10

Use it as a class attribute:

class Example:
    value = Ten()

example = Example()

print(example.value)

Output:

10

value is not stored as 10 inside example.__dict__. Python finds the descriptor on the class and invokes its __get__() method.

The important idea is:

An object stored on a class can control what attribute access does for its instances.

Why Do Descriptors Exist?

Descriptors exist to give Python controlled behavior for attribute access.

Normally, when you write:

user.name

you expect Python to retrieve a value associated with name. A descriptor allows that attribute access to trigger custom logic instead.

That logic can:

  • validate a value,
  • calculate a value when it is requested,
  • control assignment or deletion,
  • retrieve data from another location,
  • or provide reusable attribute behavior across classes.

For example, a property can make this:

user.age

look like ordinary attribute access while internally running a method:

@property
def age(self):
    return self._age

This is possible because property uses the descriptor protocol.

The important design advantage is separating the interface from the implementation. Code can continue using simple attribute syntax such as user.age, while the descriptor handles the rules behind that access.

Descriptors therefore provide a general mechanism for turning attribute access into controlled, reusable behavior rather than treating every attribute as a simple stored value.

The Descriptor Protocol

The Descriptor Protocol

The three main descriptor methods control different operations.

__get__()

Controls attribute reading:

obj.value

Typical signature:

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

Here:

self is the descriptor.

obj is the instance being accessed.

objtype is the class.

When accessed through the class:

Example.value

obj is normally None. A descriptor can use this distinction to return itself for class-level access.

__set__()

Controls assignment:

obj.value = 20

Example:

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

When assigning a value requires validation, conversion, or other behavior, this is helpful.

__delete__()

Controls deletion:

del obj.value

Example:

def __delete__(self, obj):
    ...

A descriptor does not need to implement all three methods. Defining at least one of the descriptor protocol methods is enough for an object to participate in descriptor behavior.

Why Descriptors Usually Live on the Class

Consider:

class Example:
    value = Ten()

Now:

example = Example()
example.value

invokes the descriptor.

But if you instead do:

example.value = Ten()

you have placed Ten() into the instance's attribute dictionary. That does not make that instance-level object behave as a descriptor.

Descriptor invocation is part of class-based attribute lookup. This is why descriptors are normally declared in the class body.

Conceptually:

Example
└── value ──▶ descriptor

example
└── attribute lookup
        ↓
   finds descriptor
        ↓
    __get__()

How Attribute Lookup Finds a Descriptor

The interesting part happens when Python evaluates:

obj.value

A simplified conceptual lookup is:

obj.value
   ↓
look through the class hierarchy
   ↓
is the class attribute a descriptor?
   ↓
yes → invoke descriptor protocol
   ↓
return descriptor result

The actual precedence rules matter.

Python gives data descriptors priority over entries in the instance dictionary. Instance attributes take priority over non-data descriptors.

This gives the practical order:

Data descriptor
      ↓
Instance dictionary
      ↓
Non-data descriptor
      ↓
Other class attributes

This precedence is one of the most important reasons descriptors can reliably control an attribute.

Data Descriptors vs Non-Data Descriptors

Descriptors are divided into two categories.

Data Descriptors

A descriptor is a data descriptor if it defines __set__() or __delete__().

For example:

class PositiveNumber:
    def __get__(self, obj, objtype=None):
        return obj._value

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError("Must be positive")
        obj._value = value

Because it defines __set__(), it is a data descriptor.

If the instance dictionary contains a value with the same name, the data descriptor still takes precedence.

Non-Data Descriptors

A descriptor that defines only __get__() is a non-data descriptor.

class ReadOnly:
    def __get__(self, obj, objtype=None):
        return obj._value

Due to the instance dictionary's higher priority, a non-data descriptor may be superseded by an instance attribute of the same name.

This distinction is particularly important for understanding how Python methods work.

@property: A Descriptor You Already Use

You don't normally need to implement a descriptor to create a managed attribute.

Python provides:

@property

Consider:

class Person:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

Now:

person = Person(25)

print(person.age)

When person.age is evaluated, the property object manages the attribute access.

Conceptually:

person.age
    ↓
Person.age
    ↓
property descriptor
    ↓
getter
    ↓
self._age

Python's documentation explicitly identifies property() as a descriptor-based mechanism.

@property With a Setter

A property can control both reading and writing:

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

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Age cannot be negative")
        self._age = value

Now:

person = Person(25)

person.age = 30

does not simply place 30 into person.__dict__["age"].

The assignment is handled by the property's setter.

This is useful because the public API remains:

person.age

while validation stays inside the implementation.

Why Functions Become Bound Methods

Descriptors also explain something that Python developers use constantly:

class User:
    def greet(self):
        return "Hello"

When you write:

user = User()
user.greet()

where did self come from?

Descriptor behavior is implemented by functions that are declared in classes. A function's descriptor machinery creates a bound method with that instance associated when it is accessed through an instance.

Conceptually:

user.greet
    ↓
function descriptor
    ↓
bound method
    ↓
greet(user)

This is why:

user.greet()

provides the user as the initial argument in an efficient manner.

So descriptors aren't merely a mechanism behind @property; they are part of the machinery behind ordinary Python methods.

staticmethod and classmethod

Descriptors also explain two other familiar decorators.

staticmethod

A static method does not bind the instance:

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

Calling:

Math.add(2, 3)

does not automatically supply self.

classmethod

A class method binds the class:

class User:
    @classmethod
    def create(cls):
        return cls()

In this case, Python provides the class as cls.

Descriptor machinery is used by both classmethod and staticmethod.

__set_name__(): Letting a Descriptor Know Its Attribute Name

A descriptor can define:

__set_name__()

Python calls this method during class creation and passes:

the owner class

the name assigned to the descriptor

Example:

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

Then:

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

The descriptor can determine whether it was allocated to a username or an email address without having to hardcode those names.

This becomes especially useful when one descriptor class is reused for multiple attributes.

Storing Per-Instance Data

A descriptor object is usually stored once on the class, but its behavior often needs to work independently for every instance.

Storing the actual value in the instance under a different name is one simple method:

class PositiveNumber:
    def __set_name__(self, owner, 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 value <= 0:
            raise ValueError("Must be positive")
        setattr(obj, self.private_name, value)

Use it:

class Product:
    price = PositiveNumber()

Now:

product = Product()
product.price = 100

print(product.price)

The descriptor controls access to price, while the actual value is stored separately on the instance.

The obj is None check allows Product.price to return the descriptor itself rather than trying to access an instance that does not exist.

Descriptors for Validation

Reusable validation is one useful application of Python descriptors.

Rather than writing validation on its own:

class Product:
    def __init__(self, price):
        if price <= 0:
            raise ValueError(...)

for every class and every field, a descriptor can centralize the rule.

class PositiveNumber:
    def __set_name__(self, owner, 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 value <= 0:
            raise ValueError("Value must be positive")
        setattr(obj, self.private_name, value)

Then:

class Product:
    price = PositiveNumber()
    quantity = PositiveNumber()

The same validation mechanism can be reused for both attributes.

This is where a descriptor becomes more useful than writing another individual property for every field.

Descriptors for Lazy Computation

Descriptors can also delay a calculation until an attribute is actually requested.

For example, a descriptor can compute a value the first time it is accessed, store the result, and return the stored value on later accesses.

Conceptually:

first access
    ↓
calculate
    ↓
store result
    ↓
return

later access
    ↓
return stored result

This is useful when computation is expensive and the value may never be needed.

The important point is that descriptors can control when an attribute's value is produced, not merely validate assignments.

Descriptors for ORM-Style Fields

Descriptors are also useful when an attribute needs to represent something more complex than a simple value.

An ORM-style field might allow code such as:

user.email

while internally handling:

  • validation
  • conversion
  • database mapping
  • field metadata
  • loading or storing values

The descriptor provides the attribute-style interface while hiding the underlying machinery.

The Quainy Labs chapter specifically identifies ORM-like field behavior as an important descriptor use case.

When Are Descriptors Better Than @property?

A property is often enough when one class needs to manage one attribute.

For example:

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

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError
        self._price = value

Use a custom descriptor when the same attribute behavior needs to be reused across multiple classes or fields.

For example:

class Product:
    price = PositiveNumber()

class Order:
    quantity = PositiveNumber()

class Account:
    balance = PositiveNumber()

The descriptor centralizes the behavior.

That is the real advantage, not simply writing more sophisticated code.

When Descriptors Are Too Much Machinery

Descriptors operate deep inside Python's attribute-access system. That power comes with a readability cost.

A custom descriptor is probably unnecessary when:

  • A normal attribute is enough.
  • One property solves the problem.
  • The validation is used only once.
  • The behavior is easier to understand as a method.
  • The descriptor adds more abstraction than the problem requires.

For example, this:

class User:
    @property
    def age(self):
        return self._age

does not need a custom descriptor simply because descriptors are technically involved underneath.

A good rule is:

Use the simplest abstraction that clearly expresses the required behavior.

Descriptors vs Properties

Aspect

@property

Custom Descriptor

Main purpose

Manage an attribute

General reusable attribute behavior

Reusability

Usually tied to a class attribute implementation

Can be reused across classes and attributes

Complexity

Low

Higher

Validation

Excellent for individual attributes

Excellent for repeated validation rules

Custom lookup behavior

Limited to property semantics

Highly customizable

Best use

Clean managed attributes

Frameworks and reusable attribute machinery

A property is itself a descriptor, so these are not competing concepts at the protocol level. property is one ready-made implementation of descriptor behavior.

Common Mistakes With Descriptors

Putting the Descriptor on the Instance

This does not activate descriptor behavior:

obj.field = MyDescriptor()

Descriptors normally need to be found through the class lookup mechanism.

Ignoring Data vs Non-Data Precedence

A descriptor with __set__() or __delete__() behaves differently from one that only implements __get__().

Forgetting Class Access

A descriptor should often handle:

Class.field

where obj is None.

Storing Data on the Descriptor Itself

A single descriptor object is generally shared by all instances of the class. Storing instance-specific values directly on the descriptor can therefore mix data between instances.

Store per-instance state on the instance or use another appropriate storage strategy.

Using Descriptors Everywhere

Descriptors are powerful precisely because they solve a deeper problem. If a normal attribute, method, or property works, it is usually clearer.

Summary

A descriptor is an object that participates in Python's attribute lookup mechanism using methods such as __get__(), __set__(), and __delete__(). Usually located on classes, descriptors can be called by Python's attribute-access mechanism.

The main difference is that data descriptors are given priority over instance dictionary entries, but non-data descriptors can be overridden by instance attributes. This priority gives descriptors authority over attribute access.

Understanding this method makes it easier to explain a number of Python features. Descriptor machinery is used by @property, bound methods, staticmethod, and classmethod. The same protocol is extended by custom descriptors to ORM-style fields, lazy calculation, and reusable validation.

The actual takeaway is not to replace each property with a descriptor. Use descriptors when reusable attribute-access behaviour justifies the additional complexity; otherwise, choose the simpler abstraction.

Frequently Asked Questions

1. What is a descriptor in Python?

When utilized with Python's class-based lookup system, a descriptor is an object that defines __get__(), __set__(), or __delete__() and can modify attribute access.

2. Is property a descriptor?

Yes. Python's property() implements descriptor behavior and uses the descriptor protocol to manage attribute access.

3. What is the difference between data and non-data descriptors?

A data descriptor defines __set__() or __delete__() in addition to descriptor behavior and takes precedence over an instance dictionary entry with the same name. A non-data descriptor defines only __get__(), so an instance dictionary entry can override it.

4. Why do Python methods use descriptors?

Functions stored on classes implement descriptor behavior. When accessed through an instance, the function is transformed into a bound method, which supplies the instance as the first argument.

5. When should I create a custom descriptor?

Use one when the same attribute behavior, such as validation, lazy computation, or field management, needs to be reused across multiple attributes or classes. For a single simple managed attribute, @property is usually easier to read.