Skip to content
← All articles

Descriptor-typed fields: the third way to compute a field

A dataclass wires the descriptor protocol straight through a field's default value. It is documented, elegant, usually the wrong tool — and it is how you read SQLAlchemy columns, Django fields and most ORM-adjacent code.

You have met two ways to compute a field. __post_init__ computes it once, at construction. A @property computes it on every read. There is a third, it is documented in the dataclasses reference, and almost nobody knows it exists:

If a field’s default value is a descriptor, the dataclass wires the descriptor protocol through the generated __init__.

This is not a hack. It is specified behaviour, and understanding it is the difference between “SQLAlchemy is magic” and “SQLAlchemy is a descriptor on a class attribute”.

The mechanism

class Positive:
    def __set_name__(self, owner: type, name: str) -> None:
        self._name = "_" + name

    def __get__(self, obj: object, objtype: type | None = None) -> int:
        if obj is None:
            raise AttributeError("no default")
        return getattr(obj, self._name)

    def __set__(self, obj: object, value: int) -> None:
        if value <= 0:
            raise ValueError(f"must be positive, got {value}")
        setattr(obj, self._name, value)


@dataclass
class Order:
    quantity: Positive = Positive()

Three things happen, in this order:

  1. __set_name__ fires when the class body is executed, receiving the owner class and the attribute name. This is how the descriptor learns what it is called without you repeating the name.
  2. @dataclass looks for a default by evaluating getattr(cls, "quantity") — which invokes Positive.__get__(None, Order). If that call raises AttributeError, the dataclass concludes the field has no default. That is the documented contract, and it is why the example above raises deliberately when obj is None.
  3. The generated __init__ assigns normally: self.quantity = quantity. Because quantity is a data descriptor on the class, that assignment routes into Positive.__set__, which validates and stores under a private name.

The result is a field whose validation and storage live in one reusable object, applied by declaration, with no __post_init__ anywhere.

💡Step 2 says the default is found by calling descriptor.__get__(None, cls), and that an AttributeError means "no default". Why is that a slightly dangerous rule in practice? click to reveal

Because AttributeError is the most commonly accidental exception in Python.

Any typo inside __get__ — a misspelled private attribute, a missing self._name because __set_name__ was never called, a getattr on the wrong object — raises AttributeError. The dataclass machinery cannot tell your deliberate “no default here” from a bug in the descriptor. It just concludes the field is required.

The symptom is that a field you thought had a default silently becomes mandatory, and the TypeError: __init__() missing 1 required positional argument appears at every call site rather than at the descriptor. The cause is a typo three files away.

Related: dataclasses.FrozenInstanceError also subclasses AttributeError, so descriptor code that broadly catches AttributeError will swallow frozen-mutation errors too. Catch narrowly, and always in the smallest possible block.

Why it exists

Consider a schema with forty fields, twelve of which are money amounts that must be coerced from strings to Decimal and validated as non-negative. The alternatives:

  • __post_init__ — twelve near-identical blocks per class, repeated in every class that has money fields. The logic is real, and it is copy-pasted.
  • attrs convertersfield(converter=to_money, validator=non_negative), twelve times. Concise, composable, and requires the dependency.
  • A descriptoramount: Money = Money(), twelve times, and the coercion is defined once for the whole codebase.

Descriptors win when many classes share the same coerced field type. They lose everywhere else, because of a real cost you cannot avoid.

The costs

The annotation names the descriptor, not the value. quantity: Positive says the attribute is a Positive, when everything that reads it sees an int. Every consumer now reads a type that is a lie, and Field.type reports the descriptor too — so any schema generator, serializer or fixture factory walking fields() gets Positive where it wanted int. That is the single biggest reason this pattern stays niche.

__set_name__ sees the pre-slots=True class. @dataclass(slots=True) builds a new class object from the old one’s namespace. __set_name__ already ran, on the original. Any descriptor that captured owner — to register itself, to build a per-class index, to derive a table name — is now holding a class nobody else refers to.

Descriptors and slots compete for the same class-dict name. A slot creates a member_descriptor at cls.quantity; your descriptor also wants to live at cls.quantity. They cannot both be there. In practice this means “descriptor fields” and “slots=True“ are an either/or on the same attribute.

It is invisible in review. quantity: Positive = Positive() looks like an ordinary default. Nothing on that line suggests that assignment runs validation, that reads go through a function, or that the storage lives under a different name.

💡Given those costs, why is it worth learning a pattern you will mostly choose not to write? click to reveal

Because you will spend far more time reading it than writing it.

Column("amount", Numeric) in SQLAlchemy, models.CharField(max_length=50) in Django, Field(...) in various ORMs, ndb.StringProperty() in older Google libraries — these are all this pattern. The class attribute is a descriptor; the annotation names the descriptor; reads and writes route through __get__/__set__; __set_name__ is how the column learns its own name.

Once you see that, a whole category of “how does this even work” questions collapses:

  • Why is User.email a Column object but user.email a string? __get__ is called with obj=None in the first case and the instance in the second; the class-level branch returns the descriptor so you can write select(User).where(User.email == x).
  • Why does User.email == "a" build a SQL expression instead of returning a bool? Because the thing you compared is the Column, and it defines __eq__.
  • Why does mypy need a plugin for these libraries? Because the declared type is the descriptor and the observed type is the value, and only a plugin knows the mapping.

You are not learning it to use it. You are learning it so that ORM code stops being magic.

The modern resolution

The type-level problem — annotation says descriptor, reality says value — has a clean fix: make the descriptor generic with distinct __get__ and __set__ types, so the coercion becomes visible to the type checker rather than hidden from it.

class Coerced[TIn, TOut]:
    def __get__(self, obj: object, objtype: type | None = None) -> TOut: ...
    def __set__(self, obj: object, value: TIn) -> None: ...

A checker that models the descriptor protocol — mypy does — now types reads as TOut and writes as TIn. amount: Coerced[str | Decimal, Decimal] says exactly what is true: you may assign a string, you will always read a Decimal. The asymmetry that made the pattern dishonest becomes the thing the type expresses.

This is also, not coincidentally, precisely what a converter is, and why attrs’ converters have the same “input type is not the field type” shape. The stdlib has no converter, so a generic descriptor is the nearest thing to one that does not add a dependency.

💡When should you actually reach for a descriptor-typed field, in one rule? click to reveal

When the same coerced field type appears in many classes, and the coercion is a property of the type, not of the class.

Money, Slug, NonEmptyText, PositiveInt, IsoTimestamp — value types with a normalisation rule, shared across a domain layer. Define the descriptor once, generically, and every class that declares one gets the coercion, the validation and the storage for free.

For anything else — a one-off invariant, a derived value, a cross-field check — use __post_init__. It is visible, it is local, it is trivially greppable, and the person reading the class in eighteen months does not need to know the descriptor protocol to understand what the class does.

The rule in one line: descriptors are for reusable field types, __post_init__ is for this class’s invariants. If you cannot name at least three classes that will use the descriptor, write the __post_init__.