Skip to main content

Descriptors & a peek at metaclasses

Level 4: Level 4: Engineeringhard22 mindescriptorsmetaclassesattributesmetaprogramming

Customize attribute access with a descriptor and understand how classes are created.

Attribute access you can't route around

When a rule like "a balance is never negative" lives in one setter, someone eventually assigns the field from another code path and skips the check. A descriptor moves that rule off the value and onto the attribute itself, so every read and write of an account's balance goes through the same code no matter who touches it. This is how ORMs, typed config, and form fields validate assignments in real systems. It is also a favorite interview topic because it reveals whether you understand Python's attribute machinery instead of just its syntax.

The mental model

A descriptor is any object that defines __get__, __set__, or __delete__ and is stored as a class attribute. When you write acct.balance, Python does not just look in acct.__dict__. It finds balance on the class, sees it is a descriptor, and calls type(acct).__dict__['balance'].__get__(acct, Account). Assignment calls __set__ the same way.

Check yourself
Account declares balance = Positive() in its class body. A batch job then constructs 1000 Account objects. How many Positive objects exist?

One detail drives everything: the descriptor object is created once, when the class is defined, and shared by every instance. So the per-instance value must be stored on the instance, not on the descriptor. In the demo, __set_name__ runs at class-creation time and records storage_name = "_balance", then __set__ stashes the value with setattr(instance, "_balance", value) and __get__ reads it back. Account(100).balance returns 100; Account(0) is fine; Account(-5) raises ValueError and cannot be bypassed.

Check yourself
The underscore in storage_name looks like a style choice, so you simplify __set_name__ to self.storage_name = name. What does Account(100) do now?

Pitfalls

  • Storing state on the descriptor. Writing self.value = value inside __set__ looks natural but shares one slot across all instances:
class Broken:
    def __get__(self, instance, owner): return self.value
    def __set__(self, instance, value): self.value = value  # shared!

class C: x = Broken()
a, b = C(), C()
a.x = 1
b.x = 2
print(a.x)   # 2  (b clobbered a)

The fix is exactly what the demo does: store on instance under a separate name.

  • Same storage name as the attribute. If storage_name were "balance" instead of "_balance", then setattr(instance, "balance", value) re-triggers __set__ forever and you get a RecursionError. The _ prefix is what breaks the loop.

  • Descriptor as an instance attribute. balance = Positive() must sit in the class body. Assign it inside __init__ and Python never invokes the protocol; it is just a normal attribute.

Check yourself
functools.cached_property computes once, writes the result into instance.__dict__, and later reads come straight from that dict with no recomputation. Why can @property not cache itself the same way?

Interview nuance: descriptors come in two flavors and the difference decides precedence. A data descriptor defines __set__ or __delete__ and wins over the instance __dict__. A non-data descriptor defines only __get__ and loses to it. That is why @property (data) cannot be shadowed by an instance attribute, while functools.cached_property (non-data) writes its result into instance.__dict__ on first access and is then read straight from the dict on later calls, skipping recomputation.

The peek at metaclasses

A metaclass is "the class of a class". The default is type, and class Account: ... is roughly Account = type("Account", (), namespace). A custom metaclass hooks class creation to register, validate, or inject methods:

class Meta(type):
    def __new__(mcls, name, bases, namespace):
        return super().__new__(mcls, name, bases, namespace)

class Thing(metaclass=Meta): ...

You will rarely write one. abc.ABCMeta, enum.Enum, and Django models use them under the hood. Note that @dataclass is a class decorator, not a metaclass, so type(SomeDataclass) is type. Knowing classes are objects built by type is what dissolves the "magic".

Check yourself
All three of these change what a class does, and all three get called metaclass tricks in conversation. Which one is not built on a metaclass?
Worked example (Python)
class Positive:
    def __set_name__(self, owner, name):
        self.storage_name = "_" + name

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

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("must be non-negative")
        setattr(instance, self.storage_name, value)


class Account:
    balance = Positive()

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


print(Account(100).balance)   # 100

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement the Positive descriptor so Account stores and returns a balance through it (raising ValueError on negatives). The run driver builds an Account and returns its balance.

run(100) is 100; run(0) is 0.

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement the Positive descriptor in models/fields.py: __get__ returns the stored value, __set__ raises ValueError for negatives and otherwise stores the value (the storage name comes from __set_name__). Account uses it for balance. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.