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:
-
__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. -
@dataclasslooks for a default by evaluatinggetattr(cls, "quantity")— which invokesPositive.__get__(None, Order). If that call raisesAttributeError, the dataclass concludes the field has no default. That is the documented contract, and it is why the example above raises deliberately whenobj is None. -
The generated
__init__assigns normally:self.quantity = quantity. Becausequantityis a data descriptor on the class, that assignment routes intoPositive.__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 converters —
field(converter=to_money, validator=non_negative), twelve times. Concise, composable, and requires the dependency. -
A descriptor —
amount: 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.emailaColumnobject butuser.emaila string?__get__is called withobj=Nonein the first case and the instance in the second; the class-level branch returns the descriptor so you can writeselect(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 theColumn, 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__.