Descriptors and Properties

Python 3.14 · ✓ verified by execution on 2026-07-30

Have you ever wondered how Python’s @property decorator actually works behind the scenes? It turns out that it is built on a powerful, lower-level feature called descriptors.

A descriptor is any object that defines the methods __get__(), __set__(), or __delete__(). When a class attribute is a descriptor, its special behavior overrides standard attribute lookup. Descriptors let objects customize how their attributes are looked up, stored, and deleted.

The Attribute Lookup Process

When you access an attribute like m.x, Python usually looks in the instance dictionary m.__dict__. However, if x is defined as a descriptor on the class, Python intercepts the lookup and invokes the descriptor’s __get__ method instead.

Here is a visual step-through of how the Python attribute lookup chain consults descriptors:

python · visualize
class Descriptor:
    def __get__(self, obj, type=None):
        return 42

class MyClass:
    x = Descriptor()

m = MyClass()
print(m.x)

In this visualization, notice how m.x doesn’t just read a value from memory; it actively calls the __get__ method on the Descriptor instance!

Basic Descriptors

Let’s look at a simple descriptor that always returns the constant 10. To use a descriptor, you must instantiate it as a class attribute (not an instance attribute).

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

class A:
    y = Ten()

a = A()
print(a.y)
Output
10

The key difference between a normal class attribute and a descriptor is that the descriptor runs code dynamically.

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

class A:
    x = 5
    y = Ten()

a = A()
print(a.x)
print(a.y)
Output
5
10

Dynamic Lookups

Since __get__ is a function, you can run computations inside it. The value returned by a descriptor isn’t stored in the instance dictionary; it is computed on demand. Here’s a descriptor that dynamically calculates the length of a container.

python
class DynamicLength:
    def __get__(self, obj, objtype=None):
        return len(obj.items)

class Container:
    length = DynamicLength()
    def __init__(self, items):
        self.items = items

c = Container([1, 2, 3])
print(c.length)
Output
3

The @property Decorator

The @property decorator is actually a built-in descriptor! Under the hood, it creates a descriptor object whose __get__ method calls the function you decorated.

python
class Temperature:
    def __init__(self, c):
        self._c = c
    @property
    def fahrenheit(self):
        return self._c * 9/5 + 32

t = Temperature(0)
print(t.fahrenheit)
Output
32.0

Data vs. Non-Data Descriptors

There are two kinds of descriptors:

  1. Non-data descriptors: Define only __get__(). They are typically used for methods. If an instance has an attribute in its dictionary with the same name, the instance dictionary takes precedence.
  2. Data descriptors: Define __set__() or __delete__(). If an instance has an attribute with the same name, the data descriptor takes precedence over the instance dictionary!

Validating with __set__ and __set_name__

When you want to control how an attribute is stored, you use __set__. But where do you store the value? A modern and clean way is to use __set_name__ (introduced in Python 3.6). It automatically tells the descriptor the name of the attribute it was assigned to in the class, simplifying state storage.

Here is a data descriptor that uses __set_name__ to learn its name (price) and validates that the value is not negative before saving it to the instance dictionary:

python
class Validator:
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, objtype=None):
        return obj.__dict__.get(self.name)
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError('Cannot be negative')
        obj.__dict__[self.name] = value

class Item:
    price = Validator()

i = Item()
i.price = 10
print(i.price)
Output
10

If we try to set a negative value, our validation logic catches it and raises an exception:

class Validator:
    def __set_name__(self, owner, name):
        self.name = name
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError('Cannot be negative')
        obj.__dict__[self.name] = value

class Item:
    price = Validator()

i = Item()
i.price = -5

By mastering descriptors, you gain deep insight into Python’s internals. Almost all of Python’s class features—methods, properties, classmethods, and staticmethods—are implemented using the descriptor protocol!

Check yourself

What makes a class a descriptor?

Reveal answer

Defining any of __get__, __set__ or __delete__ — The descriptor protocol is duck-typed: implement __get__/__set__/__delete__ and Python's attribute machinery uses it.

Where must a descriptor live to work at all?

Reveal answer

On the class, as a class attribute — Descriptors are only invoked when found on the class. Assign one to an instance attribute and it is just an ordinary object.

What does @property let you avoid?

Reveal answer

Writing getter and setter calls at every call site — @property keeps plain attribute syntax (obj.x) while running your code, so you can add validation without changing any caller.

A data descriptor defines __set__. What does that change?

Reveal answer

It takes priority over the instance __dict__ during lookup — Data descriptors win over instance attributes; non-data descriptors (only __get__) lose to them. That ordering is the subtle part.

Challenges

Challenge 1 +100 XP

Create a `ReadOnly` descriptor that allows a value to be set once, but raises an AttributeError if set again. Then use it in a class `Config` for the `db_url` attribute.

python
  • Test 1 — expects ""
Need a hint? (−25% XP)

Check if the attribute name is already in obj.__dict__ inside __set__.

Show solution (0 XP)
class ReadOnly:
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, objtype=None):
        return obj.__dict__.get(self.name)
    def __set__(self, obj, value):
        if self.name in obj.__dict__:
            raise AttributeError('Read only')
        obj.__dict__[self.name] = value

class Config:
    db_url = ReadOnly()

Challenge 2 +100 XP

Create a `Celsius` class with a `temperature` property. When getting it, return the value. When setting it, if the value is below -273.15, raise a ValueError.

python
  • Test 1 — expects ""
Need a hint? (−25% XP)

Use @property for the getter and @temperature.setter for the setter.

Show solution (0 XP)
class Celsius:
    def __init__(self, temperature):
        self.temperature = temperature

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, value):
        if value < -273.15:
            raise ValueError('Too cold')
        self._temperature = value