__set_name__ - The Descriptor Naming Protocol
Reading time: ~20 minutes | Level: Advanced
Predict the output of this code:
class Field:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
setattr(obj, self.private_name, value)
class User:
username = Field()
email = Field()
u = User()
u.username = "alice"
print(u.username)
print(u.__dict__)
print(User.username.public_name)
Click to reveal the answer
alice
{'_username': 'alice'}
username
The Field() descriptor never needed to be told its name. When type.__new__ created the User class, it called Field.__set_name__(self, User, "username") and Field.__set_name__(self, User, "email") automatically. The descriptor used the name to derive a private storage attribute _username, and __get__/__set__ use that derived name transparently.
What You Will Learn
- The redundancy problem that
__set_name__solves and why it existed before Python 3.6 - Exactly when and how
type.__new__calls__set_name__, including the arguments it receives - How to build self-naming descriptors that configure themselves from their attribute name
- That
__set_name__works on any class attribute - not just descriptors - The interaction between
__set_name__, metaclasses, and__init_subclass__ - Real-world patterns from Django, Pydantic, and SQLAlchemy
- Edge cases: inherited descriptors, reassignment, and dynamic attribute setting
Prerequisites
- Solid understanding of descriptors (
__get__,__set__,__delete__) - Knowledge of how
type.__new__creates class objects - Familiarity with
__init_subclass__(previous lesson) - Comfort reading Python class internals
Part 1 - The Problem Before Python 3.6
Before __set_name__ existed, every descriptor-based framework had the same painful redundancy:
# Pre-3.6: you had to repeat the name
class User:
username = StringField("username") # "username" repeated
email = StringField("email") # "email" repeated
age = IntField("age") # "age" repeated
The descriptor needed to know which attribute it was stored under so it could manage per-instance storage. But the descriptor was created before it was assigned to the class namespace, so it had no way to discover its own name automatically.
This caused real bugs:
class User:
username = StringField("email") # copy-paste error - wrong name!
email = StringField("email") # now two fields share storage
The workarounds were all ugly:
- Pass the name explicitly (redundant, error-prone)
- Use a metaclass to iterate over the class namespace after creation and inject names
- Use a class decorator to do the same post-hoc name injection
Django's ModelBase metaclass, SQLAlchemy's DeclarativeMeta, and WTForms all used approach 2 or 3. Every framework reinvented the same wheel.
PEP 487 (Python 3.6) solved this by adding __set_name__ to the class creation protocol.
Part 2 - How __set_name__ Works
After type.__new__ creates a class object and populates its namespace, it iterates over every value in the class namespace and calls __set_name__ on any object that defines it.
The signature:
def __set_name__(self, owner, name):
"""
Called by type.__new__ after the class is created.
Parameters:
self - the descriptor/attribute object itself
owner - the class that owns this attribute (e.g., User)
name - the attribute name in the class namespace (e.g., "username")
"""
pass
The call happens in this order during class creation:
Key observation: __set_name__ fires before __init_subclass__. This means by the time __init_subclass__ runs, all descriptors already know their names.
class Descriptor:
def __set_name__(self, owner, name):
print(f"__set_name__: {name} on {owner.__name__}")
self.name = name
class Base:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
print(f"__init_subclass__: {cls.__name__}")
class Child(Base):
field = Descriptor()
# Output order:
# __set_name__: field on Child
# __init_subclass__: Child
__set_name__ is called by type.__new__, not by type.__init__. If you write a custom metaclass that overrides __new__ and does not call super().__new__(), __set_name__ will not fire.
Part 3 - Building Self-Naming Descriptors
The canonical use: a descriptor that configures its own storage key.
class TypedField:
"""A descriptor that validates type on assignment and names itself."""
def __init__(self, expected_type, *, default=None):
self.expected_type = expected_type
self.default = default
# self.name will be set by __set_name__
def __set_name__(self, owner, name):
self.name = name
self.storage_name = f"_typed_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self # class-level access returns the descriptor
return getattr(obj, self.storage_name, self.default)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} expects {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.storage_name, value)
def __repr__(self):
return f"TypedField({self.expected_type.__name__}, name={self.name!r})"
class Config:
host = TypedField(str, default="localhost")
port = TypedField(int, default=8080)
debug = TypedField(bool, default=False)
# The descriptors know their names:
print(Config.host) # TypedField(str, name='host')
print(Config.port) # TypedField(int, name='port')
c = Config()
c.host = "0.0.0.0"
c.port = 443
print(c.host, c.port) # 0.0.0.0 443
try:
c.port = "not a number"
except TypeError as e:
print(e) # port expects int, got str
# Storage is clean:
print(c.__dict__) # {'_typed_host': '0.0.0.0', '_typed_port': 443}
A Validation Framework
Building on __set_name__, here is a composable validation system:
class Validator:
"""Base class for composable validators."""
def __set_name__(self, owner, name):
self.name = name
self.storage_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage_name, None)
def __set__(self, obj, value):
self.validate(value)
setattr(obj, self.storage_name, value)
def validate(self, value):
"""Override in subclasses."""
pass
class String(Validator):
def __init__(self, *, min_length=0, max_length=None):
self.min_length = min_length
self.max_length = max_length
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f"{self.name}: expected str, got {type(value).__name__}")
if len(value) < self.min_length:
raise ValueError(f"{self.name}: minimum length is {self.min_length}")
if self.max_length is not None and len(value) > self.max_length:
raise ValueError(f"{self.name}: maximum length is {self.max_length}")
class Number(Validator):
def __init__(self, *, min_value=None, max_value=None):
self.min_value = min_value
self.max_value = max_value
def validate(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name}: expected number, got {type(value).__name__}")
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.name}: minimum value is {self.min_value}")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.name}: maximum value is {self.max_value}")
class UserProfile:
username = String(min_length=3, max_length=20)
age = Number(min_value=0, max_value=150)
bio = String(max_length=500)
profile = UserProfile()
profile.username = "alice"
profile.age = 30
profile.bio = "Engineer"
try:
profile.username = "ab"
except ValueError as e:
print(e) # username: minimum length is 3
try:
profile.age = -5
except ValueError as e:
print(e) # age: minimum value is 0
Part 4 - Non-Descriptor Uses
A common misconception: __set_name__ is only for descriptors. In reality, any object with a __set_name__ method gets called when it appears as a class attribute, even if it does not implement __get__/__set__.
class Tag:
"""Not a descriptor - just wants to know its name."""
def __set_name__(self, owner, name):
self.owner_name = owner.__name__
self.attr_name = name
print(f"Tag placed on {owner.__name__}.{name}")
def __repr__(self):
return f"Tag(on={self.owner_name}.{self.attr_name})"
class Document:
title_tag = Tag()
body_tag = Tag()
# Tag placed on Document.title_tag
# Tag placed on Document.body_tag
print(Document.title_tag) # Tag(on=Document.title_tag)
print(Document.body_tag) # Tag(on=Document.body_tag)
This is useful for:
- Metadata collection: gather information about where an object was placed
- Registration: track which class and attribute name each marker is associated with
- Logging/debugging: auto-label objects based on their location in the class hierarchy
Collecting Field Metadata
class FieldInfo:
"""Tracks field metadata without being a descriptor."""
def __init__(self, description, required=True):
self.description = description
self.required = required
def __set_name__(self, owner, name):
self.name = name
# Register this field on the owner class
if "_field_info" not in owner.__dict__:
owner._field_info = {}
owner._field_info[name] = self
class APIResponse:
status = FieldInfo("HTTP status code", required=True)
data = FieldInfo("Response payload", required=True)
error = FieldInfo("Error message if any", required=False)
print(APIResponse._field_info)
# {'status': ..., 'data': ..., 'error': ...}
for name, info in APIResponse._field_info.items():
print(f" {name}: {info.description} (required={info.required})")
# status: HTTP status code (required=True)
# data: Response payload (required=True)
# error: Error message if any (required=False)
Part 5 - Interaction with Metaclasses
Understanding the execution order is critical when __set_name__, __init_subclass__, and a custom metaclass all coexist:
class Meta(type):
def __new__(mcs, name, bases, namespace):
print(f"Meta.__new__: creating {name}")
cls = super().__new__(mcs, name, bases, namespace)
# __set_name__ has ALREADY been called by super().__new__
print(f"Meta.__new__: {name} created")
return cls
def __init__(cls, name, bases, namespace):
print(f"Meta.__init__: {name}")
super().__init__(name, bases, namespace)
class Descriptor:
def __set_name__(self, owner, name):
print(f"__set_name__: {name} on {owner.__name__}")
class Base(metaclass=Meta):
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
print(f"__init_subclass__: {cls.__name__}")
class Child(Base):
field = Descriptor()
Output:
Meta.__new__: creating Base
Meta.__new__: Base created
Meta.__init__: Base
Meta.__new__: creating Child
__set_name__: field on Child
Meta.__new__: Child created
__init_subclass__: Child
Meta.__init__: Child
The complete order for Child:
Meta.__new__is enteredsuper().__new__(which istype.__new__) creates the class and calls__set_name__on all attributesMeta.__new__returns the class__init_subclass__fires on the parent (Base)Meta.__init__runs
If your metaclass __new__ does NOT call super().__new__() - for example, if it builds the class manually - then __set_name__ will not be called automatically. You would need to call it yourself by iterating over the namespace.
Part 6 - Real-World Patterns
Django Model Fields
Before Python 3.6, Django's Options.contribute_to_class and the ModelBase metaclass handled field naming. Modern Django still uses the metaclass for backwards compatibility, but the pattern maps directly to __set_name__:
# Simplified version of what Django does conceptually:
class DjangoStyleField:
def __set_name__(self, owner, name):
self.name = name
self.attname = name
self.column = name # database column name defaults to field name
# Register with the model's _meta
if hasattr(owner, "_meta"):
owner._meta.fields.append(self)
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.attname)
def __set__(self, obj, value):
obj.__dict__[self.attname] = value
Pydantic Field
Pydantic v2 uses __set_name__ internally so that Field() objects know which model attribute they belong to:
# Pydantic's approach (simplified):
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0)
# Field() uses __set_name__ to associate "name" and "price"
# with the correct field metadata
SQLAlchemy Column
SQLAlchemy's mapped_column() in the 2.0 style uses __set_name__ to determine the column name:
# SQLAlchemy 2.0 style - uses __set_name__ under the hood:
from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped
from sqlalchemy import String
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
# mapped_column.__set_name__ sets the column name to "id", "name"
Building a Configuration Schema
A complete example combining __set_name__ with __init_subclass__:
import os
class EnvField:
"""Descriptor that reads from environment variables, self-naming via __set_name__."""
def __init__(self, env_prefix=None, default=None, cast=str):
self.env_prefix = env_prefix
self.default = default
self.cast = cast
def __set_name__(self, owner, name):
self.name = name
prefix = self.env_prefix or getattr(owner, "_env_prefix", "APP")
self.env_var = f"{prefix}_{name.upper()}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
raw = os.environ.get(self.env_var)
if raw is None:
if self.default is None:
raise RuntimeError(f"Missing env var: {self.env_var}")
return self.default
return self.cast(raw)
def __repr__(self):
return f"EnvField(env_var={self.env_var!r}, default={self.default!r})"
class AppConfig:
_env_prefix = "MYAPP"
host = EnvField(default="localhost")
port = EnvField(default=8080, cast=int)
debug = EnvField(default=False, cast=lambda v: v.lower() in ("1", "true", "yes"))
# Descriptors auto-configured:
print(AppConfig.host) # EnvField(env_var='MYAPP_HOST', default='localhost')
print(AppConfig.port) # EnvField(env_var='MYAPP_PORT', default=8080)
config = AppConfig()
print(config.host) # localhost (from default, since env var is not set)
print(config.port) # 8080
Part 7 - Edge Cases
Inherited Descriptors
__set_name__ is only called for attributes defined directly in the class body. If a descriptor is inherited, __set_name__ is NOT called again in the subclass:
class Named:
def __set_name__(self, owner, name):
print(f"__set_name__ called: {owner.__name__}.{name}")
self.name = name
class Parent:
field = Named() # __set_name__ called: Parent.field
class Child(Parent):
pass # __set_name__ is NOT called again for Child
# Child.field is the same descriptor object - still named "field"
print(Child.field.name) # field - inherited, not re-named
Reassignment After Class Creation
If you assign a descriptor to a class after the class is created, __set_name__ is NOT called:
class Desc:
def __set_name__(self, owner, name):
print(f"__set_name__: {owner.__name__}.{name}")
self.name = name
class MyClass:
x = Desc() # __set_name__: MyClass.x
# Dynamic assignment - __set_name__ is NOT called:
MyClass.y = Desc()
print(hasattr(MyClass.y, "name")) # False - name was never set
If you need __set_name__ to fire for dynamically added attributes, you must call it manually:
desc = Desc()
MyClass.z = desc
desc.__set_name__(MyClass, "z")
print(desc.name) # z
Same Descriptor Instance on Multiple Attributes
A single descriptor instance can only be named once. If you reuse the same instance (which you should not do, but it can happen by accident), the second __set_name__ call overwrites the first:
class Desc:
def __set_name__(self, owner, name):
self.name = name
shared = Desc()
class Broken:
a = shared
b = shared # overwrites - shared.name is now "b"
print(shared.name) # b - the last __set_name__ call wins
Never share a single descriptor instance across multiple attributes. Each attribute should get its own descriptor instance. This is why you use Field() (a call that creates a new instance), not Field (a shared reference).
__set_name__ Errors
If __set_name__ raises an exception, the class creation fails with a RuntimeError that wraps the original exception:
class BadDesc:
def __set_name__(self, owner, name):
raise ValueError("I refuse this name")
try:
class Doomed:
x = BadDesc()
except RuntimeError as e:
print(type(e).__name__, e)
# RuntimeError: Error calling __set_name__ on 'BadDesc' instance 'x'
# in 'Doomed'
print(type(e.__cause__).__name__, e.__cause__)
# ValueError: I refuse this name
Key Takeaways
__set_name__(self, owner, name)is called bytype.__new__on every class attribute that defines it- It fires after class creation but before
__init_subclass__- descriptors know their names before subclass hooks run - It eliminates the
name = Field("name")redundancy that plagued pre-3.6 descriptor frameworks - Any object with
__set_name__gets called, not just descriptors - useful for metadata collection __set_name__is NOT called for inherited attributes or dynamically assigned attributes- Never share a single descriptor instance across multiple class attributes
- Django, Pydantic, and SQLAlchemy all rely on
__set_name__(or equivalent metaclass logic) for field auto-configuration
Graded Practice Challenges
Level 1 - Predict the Output
Question 1:
class Marker:
instances = []
def __set_name__(self, owner, name):
self.owner = owner.__name__
self.name = name
Marker.instances.append((self.owner, self.name))
class A:
x = Marker()
y = Marker()
class B(A):
z = Marker()
print(Marker.instances)
Answer
[('A', 'x'), ('A', 'y'), ('B', 'z')]
__set_name__ fires for x and y when class A is created, and for z when class B is created. It does NOT re-fire for inherited x and y in B.
Question 2:
class AutoName:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name, f"<unset:{self.name}>")
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class Point:
x = AutoName()
y = AutoName()
p = Point()
print(p.x)
p.x = 10
print(p.x)
print(p.__dict__)
Answer
<unset:x>
10
{'x': 10}
The descriptor stores values directly in obj.__dict__ using the attribute name. When unset, it returns a placeholder string. After assignment, the value is stored under "x" in the instance dict. Since this is a data descriptor (has __set__), __get__ still fires even though "x" exists in obj.__dict__ - but __get__ explicitly reads from obj.__dict__, so it returns 10.
Question 3:
class Tracker:
def __set_name__(self, owner, name):
self.name = name
if not hasattr(owner, '_tracked'):
owner._tracked = []
owner._tracked.append(name)
class Base:
a = Tracker()
class Child(Base):
b = Tracker()
print(Base._tracked)
print(Child._tracked)
print(Base._tracked is Child._tracked)
Answer
['a', 'b']
['a', 'b']
True
This is a subtle bug. When Child is created, hasattr(owner, '_tracked') returns True because Child inherits _tracked from Base. So the __set_name__ for b appends to the SAME list. Both Base._tracked and Child._tracked reference the same list object. The fix would be to check '_tracked' in owner.__dict__ instead of hasattr.
Level 2 - Debug Challenge
This code attempts to build a field system where each field tracks its creation order, but it has a bug that causes incorrect ordering:
class OrderedField:
_counter = 0
def __init__(self, required=True):
self.required = required
OrderedField._counter += 1
self.creation_order = OrderedField._counter
def __set_name__(self, owner, name):
self.name = name
if not hasattr(owner, "_fields"):
owner._fields = {}
owner._fields[name] = self
class Schema:
name = OrderedField()
email = OrderedField()
age = OrderedField(required=False)
class ExtendedSchema(Schema):
phone = OrderedField()
# Expected: ExtendedSchema._fields should contain only "phone"
# Schema._fields should contain only "name", "email", "age"
# Actual: What happens?
print(list(Schema._fields.keys()))
print(list(ExtendedSchema._fields.keys()))
Hint
Run the code and check if Schema._fields and ExtendedSchema._fields are the same object. Also check if ExtendedSchema._fields contains all four fields or just one.
Solution
The bug is the same as Question 3 above: hasattr(owner, '_fields') returns True for ExtendedSchema because it inherits from Schema. So phone is appended to Schema._fields, corrupting the parent.
Output is:
['name', 'email', 'age', 'phone']
['name', 'email', 'age', 'phone']
Both reference the same dict.
Fix: check owner.__dict__ instead of hasattr, and copy inherited fields:
def __set_name__(self, owner, name):
self.name = name
if "_fields" not in owner.__dict__:
# Copy inherited fields, then extend
inherited = {}
for base in owner.__mro__[1:]:
if "_fields" in base.__dict__:
inherited.update(base._fields)
break
owner._fields = inherited.copy()
owner._fields[name] = self
Now Schema._fields has 3 fields, ExtendedSchema._fields has 4, and they are separate dictionaries.
Level 3 - Design Challenge
Build a Form system with the following requirements:
FormFielddescriptors that auto-name themselves via__set_name__- Support
StringField(max_length=100),IntField(min_val=0, max_val=999),BoolField(default=False) - Each field validates on
__set__and raisesValidationErrorwith the field name in the message - A
Formbase class that uses__init_subclass__to collect all fields into a_fieldsdict (in definition order) Form.validate(instance)classmethod that checks all required fields are set- Fields should support
required=True/False(default True)
Test with a LoginForm that has username, password, and remember_me fields.
Solution Sketch
class ValidationError(Exception):
pass
class FormField:
def __init__(self, *, required=True, default=None):
self.required = required
self.default = default
def __set_name__(self, owner, name):
self.name = name
self.storage = f"_field_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage, self.default)
def __set__(self, obj, value):
self.validate(value)
setattr(obj, self.storage, value)
def validate(self, value):
pass # override in subclasses
class StringField(FormField):
def __init__(self, *, max_length=None, **kwargs):
super().__init__(**kwargs)
self.max_length = max_length
def validate(self, value):
if not isinstance(value, str):
raise ValidationError(f"{self.name}: expected str")
if self.max_length and len(value) > self.max_length:
raise ValidationError(f"{self.name}: max length {self.max_length}")
class IntField(FormField):
def __init__(self, *, min_val=None, max_val=None, **kwargs):
super().__init__(**kwargs)
self.min_val = min_val
self.max_val = max_val
def validate(self, value):
if not isinstance(value, int):
raise ValidationError(f"{self.name}: expected int")
if self.min_val is not None and value < self.min_val:
raise ValidationError(f"{self.name}: min is {self.min_val}")
if self.max_val is not None and value > self.max_val:
raise ValidationError(f"{self.name}: max is {self.max_val}")
class BoolField(FormField):
def __init__(self, **kwargs):
kwargs.setdefault("default", False)
super().__init__(**kwargs)
def validate(self, value):
if not isinstance(value, bool):
raise ValidationError(f"{self.name}: expected bool")
class Form:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls._fields = {}
for base in reversed(cls.__mro__):
for attr_name, attr_val in base.__dict__.items():
if isinstance(attr_val, FormField):
cls._fields[attr_name] = attr_val
@classmethod
def validate(cls, instance):
errors = []
for name, field in cls._fields.items():
value = getattr(instance, name)
if field.required and value is None:
errors.append(f"{name}: required field is missing")
if errors:
raise ValidationError("; ".join(errors))
class LoginForm(Form):
username = StringField(max_length=50)
password = StringField(max_length=128)
remember_me = BoolField(required=False)
form = LoginForm()
form.username = "alice"
form.password = "secret123"
LoginForm.validate(form) # passes
print(form.remember_me) # False (default)
What's Next
In the next lesson, we dive into Dynamic Class Creation - building classes at runtime with type(), understanding the full class creation pipeline, and exploring how @dataclass and namedtuple generate code under the hood.
