Skip to main content

__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:

  1. Pass the name explicitly (redundant, error-prone)
  2. Use a metaclass to iterate over the class namespace after creation and inject names
  3. 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
note

__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:

  1. Meta.__new__ is entered
  2. super().__new__ (which is type.__new__) creates the class and calls __set_name__ on all attributes
  3. Meta.__new__ returns the class
  4. __init_subclass__ fires on the parent (Base)
  5. Meta.__init__ runs
tip

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
danger

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 by type.__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:

  1. FormField descriptors that auto-name themselves via __set_name__
  2. Support StringField(max_length=100), IntField(min_val=0, max_val=999), BoolField(default=False)
  3. Each field validates on __set__ and raises ValidationError with the field name in the message
  4. A Form base class that uses __init_subclass__ to collect all fields into a _fields dict (in definition order)
  5. Form.validate(instance) classmethod that checks all required fields are set
  6. 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.

© 2026 EngineersOfAI. All rights reserved.