PythonMastery
advanced 22 min read · lesson 2 of 9 in Python Advanced

Descriptors & Properties: The Attribute Protocol

1 · The lesson

read

Every time you write obj.x, Python runs an algorithm. Most of the time it's "look in obj.__dict__, fall back to the class." But if the class attribute is a descriptor, Python calls that descriptor's __get__ method, and the descriptor decides what obj.x returns.

That one hook is the basis of @property, @classmethod, @staticmethod, method binding (the reason self works), functools.cached_property, ORM field types (Django models.CharField, SQLAlchemy Column), and other features you have used without knowing it. This lesson explains the protocol, so you can see how those features work.


1. The Descriptor Protocol in One Sentence

A descriptor is any object whose class implements at least one of:

  • __get__(self, obj, objtype): runs when the attribute is read.
  • __set__(self, obj, value): runs when the attribute is assigned.
  • __delete__(self, obj): runs when the attribute is deleted.

That is all. Define one of those methods, put an instance of your class as a class attribute on another class, and every read or write of that attribute on instances runs your code.

python
class Const:
    def __get__(self, obj, objtype=None):
        return 42

class Foo:
    answer = Const()            # descriptor instance, attached at class-body level

f = Foo()
print(f.answer)                 # 42           (Const.__get__ ran)
print(Foo.answer)               # 42           (this works on the class too)

Three names to keep straight in __get__:

  • self: the descriptor instance (the Const()).
  • obj: the instance the attribute is being accessed on (the Foo()), or None if accessed on the class.
  • objtype: the class (Foo).

2. __set_name__: Knowing Your Own Name

When you write x = Positive() inside a class body, the descriptor doesn't know it was named x. Python 3.6 added __set_name__, which runs when the class is created with the owner class and the attribute name:

python
class Tagged:
    def __set_name__(self, owner, name):
        print(f"attached to {owner.__name__} as {name!r}")
        self.name = name

class Foo:
    a = Tagged()
    b = Tagged()
# attached to Foo as 'a'
# attached to Foo as 'b'

Any validating descriptor that stores per-instance state is built on this. You use name as the key for the value inside obj.__dict__ (see Section 3).


3. A Validating Descriptor: Positive

A descriptor that raises if you try to set a non-positive value:

python
class Positive:
    def __set_name__(self, owner, name):
        self._name = name                       # remember the attribute name

    def __get__(self, obj, objtype=None):
        if obj is None:                         # access on the class itself
            return self
        return obj.__dict__[self._name]

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self._name} must be > 0, got {value}")
        obj.__dict__[self._name] = value


class Order:
    quantity = Positive()
    price = Positive()

    def __init__(self, quantity, price):
        self.quantity = quantity                # routes through Positive.__set__
        self.price = price


o = Order(3, 19.99)
print(o.quantity, o.price)      # 3 19.99
o.quantity = 5                  # validated and stored
# o.quantity = -1               # ValueError: quantity must be > 0, got -1
# Order(0, 10)                  # ValueError: quantity must be > 0, got 0

Two things to notice:

  • State lives on the instance, not on the descriptor. We write into obj.__dict__[self._name]. One Positive() is shared by every Order, and only the value is per-instance.
  • The obj is None check in __get__: when someone writes Order.quantity (on the class, not an instance), return the descriptor itself, so introspection still works.

4. Data vs Non-Data Descriptors: The Lookup Order

Python's attribute lookup has a specific priority:

1. Data descriptors on the class (they have __set__ or __delete__). These come first.
2. The instance __dict__.
3. Non-data descriptors on the class (only __get__). The instance dict comes before them.
4. Class attributes (regular values).

python
class Loud:
    def __get__(self, obj, objtype=None):
        return "loud!"

class Quiet:
    def __get__(self, obj, objtype=None):
        return "quiet"
    def __set__(self, obj, value):
        obj.__dict__["q"] = value

class Foo:
    l = Loud()                  # non-data descriptor
    q = Quiet()                 # data descriptor

f = Foo()
f.__dict__["l"] = "shadowed"    # instance dict wins over non-data descriptor
f.__dict__["q"] = "shadowed"    # data descriptor still wins

print(f.l)                      # shadowed
print(f.q)                      # quiet

This is why a function (a non-data descriptor, because it only defines __get__) can be replaced on one instance by putting something in instance.__dict__, but a @property (a data descriptor) cannot.


5. Methods Are Descriptors

Functions defined with def implement __get__. This is how method binding works. self is not a keyword. The descriptor protocol passes the instance in as the first argument:

python
class Foo:
    def hello(self):
        return f"hi from {self}"

f = Foo()
print(Foo.hello)                # <function Foo.hello at 0x...>     (the plain function)
print(f.hello)                  # <bound method Foo.hello of ...>   (the descriptor made a bound method)

# What actually happens:
import types
manually_bound = Foo.hello.__get__(f, Foo)
print(manually_bound)           # <bound method Foo.hello of ...>
print(manually_bound())         # hi from <__main__.Foo object>

Foo.hello.__get__(f, Foo) returns a types.MethodType with f already bound as the first argument. Every method call goes through this: f.hello() is Foo.hello.__get__(f, Foo)().

Methods are non-data descriptors (no __set__), which is why you can replace a method on one instance by assigning to instance.method = ... (this is called monkey-patching). The instance dict comes first.


6. @property IS a Descriptor

property is a built-in descriptor class. The decorator form is a shorter way to use it:

python
class C:
    @property
    def area(self):
        return self._r ** 2 * 3.14159

is roughly equivalent to:

python
class _AreaDescriptor:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj._r ** 2 * 3.14159

class C:
    area = _AreaDescriptor()

The real property is a single class that takes fget, fset and fdel as __init__ arguments. @property.setter builds a new property object with the setter filled in. Once you know this, you can answer questions like "why does my property setter stop working after I redefine the getter in a subclass?"

@classmethod and @staticmethod are also descriptors. classmethod.__get__ returns a method bound to the class instead of the instance, and staticmethod.__get__ returns the underlying function unbound. You will almost never need to know this. But it explains why all three "method types" are defined in the class body and all work when you access them.


7. A Real-World Descriptor: Lazy

Compute a value once on first access, then cache it on the instance for every later read. The method: write into obj.__dict__ from __get__. Normal attribute lookup checks the instance dict before non-data descriptors, so on the next access Python finds the cached value there, and your __get__ never runs again.

python
class Lazy:
    def __init__(self, func):
        self.func = func

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

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = self.func(obj)
        obj.__dict__[self._name] = value        # cached here, so the next access skips the descriptor
        return value


class Dataset:
    def __init__(self, rows):
        self.rows = rows

    @Lazy
    def summary(self):
        print("computing summary (expensive)...")
        return {
            "count": len(self.rows),
            "total": sum(self.rows),
        }


d = Dataset([1, 2, 3, 4, 5])
print(d.summary)                # computing summary (expensive)... {'count': 5, 'total': 15}
print(d.summary)                # {'count': 5, 'total': 15}        (not computed again)
print(d.summary)                # the same result again

The standard library already has this. Use functools.cached_property:

python
from functools import cached_property

class Dataset:
    def __init__(self, rows):
        self.rows = rows

    @cached_property
    def summary(self):
        return {"count": len(self.rows), "total": sum(self.rows)}

Always prefer the standard library version. It handles __set_name__ for you and gives a clear error on classes with __slots__ (it stores the value in the instance __dict__). It is not thread-safe: since Python 3.12 it has no lock. Writing your own Lazy is a teaching exercise, but importing cached_property is the answer in production.


8. When to Write Your Own Descriptor

A simple rule:

  • One attribute, one class, simple logic → @property.
  • The same validation logic on many attributes across many classes → descriptor.

Five @propertys with copy-pasted validation are a sign that you need a descriptor. One Typed(int) descriptor used on quantity, count, index, age across Order, Inventory, Page, User is better: you write the validation once and use it everywhere.

Real-world descriptor users:

  • Django ORM: on a model instance, fields such as models.CharField(max_length=100) are read through descriptors.
  • SQLAlchemy: Column(...) and relationship(...) on a mapped class become descriptors that load and track the values.

You'll mostly configure these. You'll occasionally write a small one for a project-specific validation rule. If you need more than that, you are probably building a framework.


9. Descriptors and dataclasses

Dataclass fields work with the descriptor protocol. If you use a descriptor as a field's default value, the __init__ that the dataclass generates goes through it:

python
from dataclasses import dataclass

class NonEmpty:
    def __set_name__(self, owner, name):
        self._name = name
    def __get__(self, obj, objtype=None):
        if obj is None: return self
        return obj.__dict__[self._name]
    def __set__(self, obj, value):
        if not value:
            raise ValueError(f"{self._name} cannot be empty")
        obj.__dict__[self._name] = value


@dataclass
class User:
    name: str = NonEmpty()
    email: str = NonEmpty()


u = User("Linus", "linus@example.com")
print(u)                        # User(name='Linus', email='linus@example.com')
# User("", "x@y")               # ValueError: name cannot be empty

A descriptor lets you add validation to a dataclass. For the simple case "this field must follow a rule", it is clearer than __post_init__.


Common Mistakes

1. Storing per-instance state on the descriptor itself (a common bug)

python
class Bad:
    def __get__(self, obj, objtype=None):
        return self.value
    def __set__(self, obj, value):
        self.value = value          # WRONG: this stores the value on the descriptor,
                                    #         so every Foo object shares one value!

class Foo:
    x = Bad()

a, b = Foo(), Foo()
a.x = 1
b.x = 2
print(a.x)                          # 2     (a and b share one value!)

The descriptor is one object held as a class attribute. Store the value in obj.__dict__[self._name] instead, which belongs to each instance.

2. Confusing obj and objtype in __get__

obj is the instance (or None if accessed on the class). objtype is the class. Mixing them up gives you methods that return class-level data when you wanted instance-level (or vice versa). Always handle the obj is None case explicitly: return self, so introspection on the class still works.

3. Forgetting __set_name__ and manually passing names

python
class Field:
    def __init__(self, name):       # easy to break, because name must match the attribute
        self.name = name

class Foo:
    x = Field("x")                  # the name "x" is typed twice
    y = Field("y")                  # easy to mistype

__set_name__ receives the name automatically when the class is created. Use it. Code written before Python 3.6 passes names by hand because there was no other way, but new code shouldn't.

4. Writing a descriptor when a @property would do

A descriptor used in exactly one place, by one attribute, on one class, is too much. @property is shorter, more idiomatic, and easier for the next person to read. Change it to a descriptor only when you would be writing the same logic for the third time.

5. Expecting descriptors to fire on dict-style access

python
class Foo:
    x = SomeDescriptor()

f = Foo()
f.x                                 # the descriptor runs
f.__dict__["x"] = 42                # a direct dict write skips the descriptor
print(f.__dict__)                   # {'x': 42}
+ setup added so this can run · defines SomeDescriptor
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def SomeDescriptor(*_a, **_kw):
    print('-> SomeDescriptor() called')
    return _AutoMock('SomeDescriptor()')

Descriptors hook into normal attribute access (f.x = ...). Reaching directly into __dict__ skips them. This is usually useful (the descriptor itself uses it to store state). But it causes bugs when test code or other code writes to __dict__ by accident. (setattr does not skip the descriptor. It works like f.x = ....)


🎯 Your Turn: Build a Typed Descriptor

Build Typed(expected_type), a descriptor that enforces a type every time the attribute is set, raising TypeError with a clear message that includes the attribute name. Requirements:

  • name = Typed(str) and age = Typed(int) should work as class attributes.
  • Setting a value of the wrong type raises TypeError: <attr_name> must be <type>, got <actual_type>.
  • Setting a value of the correct type stores it per-instance (no shared state between instances).
  • Reading returns the stored value. Reading before any set should raise AttributeError (Python's default for missing attributes).
  • Use __set_name__ so you don't have to pass the name in manually.
python
class Typed:
    def __init__(self, expected_type):
        # TODO 1: store expected_type
        ...

    def __set_name__(self, owner, name):
        # TODO 2: remember the attribute name
        ...

    def __get__(self, obj, objtype=None):
        # TODO 3: if obj is None, return self
        # TODO 4: pull the value from obj.__dict__
        ...

    def __set__(self, obj, value):
        # TODO 5: check the type with isinstance, raise TypeError if wrong
        # TODO 6: store in obj.__dict__[self._name]
        ...


class Person:
    name = Typed(str)
    age = Typed(int)

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


p = Person("Linus", 28)
print(p.name, p.age)
# Person(123, 28)               # TypeError: name must be <class 'str'>, got <class 'int'>
Hint 1: Per-instance storage in obj.__dict__ Inside __set__, write obj.__dict__[self._name] = value. Inside __get__, read it back the same way. Never write self.value = ... on the descriptor. That is the shared-state bug from Common Mistake #1.
Hint 2: Using __set_name__ for the attribute name Python calls __set_name__(self, owner, name) automatically when the class body finishes. Save name as self._name so your error messages and the __dict__ key can use it. No need for the user to pass the name as an argument.
Show full solution
python
class Typed:
    def __init__(self, expected_type):
        self.expected_type = expected_type

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

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self                                 # access on the class
        try:
            return obj.__dict__[self._name]
        except KeyError:
            raise AttributeError(
                f"{type(obj).__name__!r} object has no attribute {self._name!r}"
            ) from None

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self._name} must be {self.expected_type}, got {type(value)}"
            )
        obj.__dict__[self._name] = value


class Person:
    name = Typed(str)
    age = Typed(int)

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


# Normal use, with valid values
p = Person("Linus", 28)
print(p.name, p.age)            # Linus 28
p.age = 29
print(p.age)                    # 29

# Type errors
try:
    Person(123, 28)
except TypeError as e:
    print(e)                    # name must be <class 'str'>, got <class 'int'>

try:
    p.age = "old"
except TypeError as e:
    print(e)                    # age must be <class 'int'>, got <class 'str'>

# Each instance keeps its own values (no shared state)
p2 = Person("Ada", 36)
print(p.name, p2.name)          # Linus Ada            (each object keeps its own name)

A few improvements you would add in real code:

  • bool subclasses int in Python, so Typed(int) accepts True/False. If you want to reject bools, add and not isinstance(value, bool).
  • Subclass acceptance: isinstance accepts subclasses by design. Typed(Animal) will accept any Dog. This is usually what you want, but not always, in which case use type(value) is self.expected_type.
  • Tuples of types: isinstance accepts (int, float), meaning "any of these". So Typed((int, float)) already works without changing a line.
  • Default values: supply a default= argument in __init__ and return it from __get__ when the key is missing, instead of raising AttributeError.

Libraries such as Pydantic, attrs and Django (models.CharField) give you typed or validated fields with many more features. The basic idea is the one you just built: check the value when it is set, and store it per instance.


What You Learned

  • A descriptor is any object whose class defines __get__, __set__, or __delete__. It controls attribute access on instances of the class that holds it as a class attribute.
  • __set_name__(self, owner, name) (3.6+) gives the descriptor its own attribute name when the class is created. Validating descriptors depend on it.
  • Data descriptors (with __set__) come before the instance __dict__. Non-data descriptors (only __get__) come after it. That's why you can replace a method on one instance, but not a @property.
  • Functions are non-data descriptors: their __get__ returns a method with self already bound. This is how method calls work.
  • @property, @classmethod, @staticmethod are all built-in descriptor classes. The decorator form is a shorter way to use them.
  • functools.cached_property is the standard library descriptor for "compute once, cache on the instance". Always prefer it over writing your own Lazy.
  • Store per-instance state in obj.__dict__[self._name], never on self (the descriptor itself is shared across every owning instance).
  • Write your own descriptor only when the same logic is repeated across many attributes across many classes. Otherwise @property is enough.

Next: Context Managers: the with protocol, __enter__/__exit__, and contextlib for resource-safe code that cleans up on every path.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.