Skip to main content

Descriptors - The Protocol That Powers Python's Object Model

Prediction Puzzle

Before we begin, predict the output of this program. Do not run it first.

class Tracker:
def __get__(self, obj, objtype=None):
print(f"__get__ called: obj={obj is not None}, objtype={objtype.__name__}")
return 42

def __set__(self, obj, value):
print(f"__set__ called: value={value}")

class Ghost:
def __get__(self, obj, objtype=None):
print(f"Ghost __get__ called")
return 99

class Host:
tracked = Tracker()
ghost = Ghost()

h = Host()
h.__dict__["tracked"] = "instance shadow attempt"
h.__dict__["ghost"] = "instance shadow attempt"

print(h.tracked)
print(h.ghost)

Take a moment. Write down your answer.

Reveal the output
__get__ called: obj=True, objtype=Host
42
instance shadow attempt

Tracker defines __set__, making it a data descriptor. Data descriptors cannot be shadowed by instance __dict__ entries - the descriptor always wins. So h.tracked invokes __get__ and returns 42, ignoring the "instance shadow attempt" sitting in h.__dict__.

Ghost only defines __get__, making it a non-data descriptor. Non-data descriptors lose to instance __dict__ entries. So h.ghost finds "instance shadow attempt" in h.__dict__ and returns it directly, never calling Ghost.__get__.

This distinction is the single most important concept in this lesson. Everything else flows from it.

What You Will Learn

  • What makes an object a descriptor and how the three dunder methods (__get__, __set__, __delete__) form the protocol
  • The critical difference between data descriptors and non-data descriptors, and why it matters for attribute shadowing
  • The complete CPython attribute lookup algorithm - the exact priority chain that runs on every dot access
  • How property is implemented as a data descriptor, with a full reimplementation
  • How classmethod and staticmethod are descriptor classes, with full reimplementations
  • Why obj.method() works - functions are non-data descriptors that produce bound methods
  • How to build production-grade validator descriptors for type checking, range validation, and constraint enforcement
  • How __set_name__ solved the descriptor naming problem introduced in Python 3.6

Prerequisites

You should be comfortable with:

  • Python classes, instances, and __dict__ on both
  • The attribute resolution chain (instance.__dict__ vs type.__dict__ vs MRO)
  • Using @property, @classmethod, @staticmethod as consumers (you will learn how they work internally here)
  • Basic understanding of the MRO (Method Resolution Order)

Part 1 - What Is a Descriptor?

A descriptor is any object that defines at least one of these three methods:

class SomeDescriptor:
def __get__(self, obj, objtype=None):
"""Called when the descriptor is accessed as an attribute."""
...

def __set__(self, obj, value):
"""Called when the descriptor is assigned to as an attribute."""
...

def __delete__(self, obj):
"""Called when the descriptor is deleted as an attribute."""
...

That is the entire protocol. There is no base class to inherit from, no registration mechanism, no metaclass required. If your object has __get__, it is a descriptor. Python's attribute access machinery will detect it and invoke the protocol automatically.

The critical constraint: the descriptor object must live on the class, not on the instance. If you store a descriptor object in an instance's __dict__, it is just a regular attribute - Python will not invoke the protocol.

class Descriptor:
def __get__(self, obj, objtype=None):
return "descriptor invoked"

class MyClass:
class_level = Descriptor() # This IS a descriptor - lives on the class

obj = MyClass()
obj.instance_level = Descriptor() # This is NOT a descriptor - lives on the instance

print(obj.class_level) # "descriptor invoked" - protocol activated
print(obj.instance_level) # <__main__.Descriptor object at 0x...> - just an object

The Method Signatures

Each method receives specific arguments:

Methodselfobjobjtype / value
__get__(self, obj, objtype)The descriptor instanceThe instance accessing it (None if accessed on class)The owner class
__set__(self, obj, value)The descriptor instanceThe instance being assigned toThe value being assigned
__delete__(self, obj)The descriptor instanceThe instance being deleted from-
class Verbose:
def __get__(self, obj, objtype=None):
print(f" __get__: self={type(self).__name__}, obj={obj}, objtype={objtype}")
return "value"

def __set__(self, obj, value):
print(f" __set__: self={type(self).__name__}, obj={obj}, value={value}")

def __delete__(self, obj):
print(f" __delete__: self={type(self).__name__}, obj={obj}")

class Owner:
attr = Verbose()

o = Owner()

print("--- Access on instance ---")
o.attr
# __get__: self=Verbose, obj=<__main__.Owner object at 0x...>, objtype=<class '__main__.Owner'>

print("--- Access on class ---")
Owner.attr
# __get__: self=Verbose, obj=None, objtype=<class '__main__.Owner'>

print("--- Assignment ---")
o.attr = 10
# __set__: self=Verbose, obj=<__main__.Owner object at 0x...>, value=10

print("--- Deletion ---")
del o.attr
# __delete__: self=Verbose, obj=<__main__.Owner object at 0x...>

Notice that when accessed on the class itself (Owner.attr), obj is None. This is how descriptors distinguish between class-level and instance-level access.

tip

The objtype parameter in __get__ is optional by convention. Starting with Python 3.6+, you can omit it and still have a valid descriptor. However, including it is recommended - it lets your descriptor behave correctly when accessed from either the class or the instance.

Part 2 - Data Descriptors vs Non-Data Descriptors

This distinction is the architectural foundation of Python's attribute system. Get this wrong and nothing else will make sense.

Data descriptor: Defines __set__ and/or __delete__ (in addition to __get__).

Non-data descriptor: Defines only __get__.

class DataDescriptor:
"""I define __get__ AND __set__ - I am a data descriptor."""
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass

class NonDataDescriptor:
"""I define ONLY __get__ - I am a non-data descriptor."""
def __get__(self, obj, objtype=None):
return "non-data descriptor"

Why Does This Matter?

The difference controls priority in attribute lookup:

Lookup stepData descriptorNon-data descriptor
Priority vs instance __dict__Wins - cannot be shadowedLoses - instance __dict__ takes priority
Attribute write (obj.x = val)Intercepted by __set__Goes directly into instance.__dict__
Attribute delete (del obj.x)Intercepted by __delete__ (if defined)Deletes from instance.__dict__

Here is the proof:

class DataDesc:
def __get__(self, obj, objtype=None):
return "from DataDesc.__get__"
def __set__(self, obj, value):
print(f"DataDesc.__set__ intercepted: {value}")

class NonDataDesc:
def __get__(self, obj, objtype=None):
return "from NonDataDesc.__get__"

class Demo:
data = DataDesc()
nondata = NonDataDesc()

d = Demo()

# Attempt to shadow both via instance __dict__
d.__dict__["data"] = "shadow"
d.__dict__["nondata"] = "shadow"

print(d.data) # "from DataDesc.__get__" - data descriptor wins over instance __dict__
print(d.nondata) # "shadow" - instance __dict__ wins over non-data descriptor

Why This Design?

This is not arbitrary. It serves a precise engineering purpose.

property needs to intercept writes. If you define @property with a setter, you expect obj.x = val to call your setter - not silently write to instance.__dict__ and bypass your validation logic. Because property defines __set__, it is a data descriptor and always wins.

Functions (methods) should be shadowable. If you assign obj.method = something, you probably want that to work. Functions only define __get__, making them non-data descriptors, so instance __dict__ entries can override them.

danger

A common mistake: defining __get__ alone and expecting it to intercept writes. It will not. Without __set__, your descriptor is non-data, and obj.x = val writes directly to instance.__dict__, after which your __get__ is never called again (because instance.__dict__ now shadows it).

The __set__ Trap Door

You can make a descriptor "read-only" by defining a __set__ that raises:

class ReadOnly:
def __init__(self, value):
self._value = value

def __get__(self, obj, objtype=None):
return self._value

def __set__(self, obj, value):
raise AttributeError("read-only attribute")

class Config:
version = ReadOnly("1.0.0")

c = Config()
print(c.version) # "1.0.0"
c.version = "2.0.0" # AttributeError: read-only attribute

The key insight: you must define __set__ even to prevent writes. Without it, the write goes to instance.__dict__ and silently shadows the descriptor.

Part 3 - The Attribute Lookup Algorithm

Every time you write obj.attr, Python executes a specific algorithm. Understanding it precisely removes all guesswork from attribute behavior.

Here is the algorithm as CPython implements it in type.__getattribute__ (simplified but accurate):

def object_getattribute(obj, name):
"""Pseudocode for type.__getattribute__ - the real C implementation."""
objtype = type(obj)

# Step 1: Search the MRO for a data descriptor
for base in objtype.__mro__:
if name in base.__dict__:
descriptor = base.__dict__[name]
if has_data_descriptor_methods(descriptor):
# Data descriptor found - it wins
return type(descriptor).__get__(descriptor, obj, objtype)
break # Found something, but not a data descriptor - remember it

# Step 2: Check instance __dict__
if name in obj.__dict__:
return obj.__dict__[name]

# Step 3: Check for non-data descriptor (or plain class variable)
for base in objtype.__mro__:
if name in base.__dict__:
descriptor = base.__dict__[name]
if hasattr(type(descriptor), "__get__"):
# Non-data descriptor - call __get__
return type(descriptor).__get__(descriptor, obj, objtype)
else:
# Plain class variable - return it directly
return descriptor

# Step 4: Nothing found
raise AttributeError(f"'{objtype.__name__}' object has no attribute '{name}'")
note

The real CPython implementation is in C (Objects/object.c, function _PyObject_GenericGetAttr). The pseudocode above captures the semantics faithfully. One subtlety omitted: if the class defines __getattr__, it is called as a final fallback before AttributeError is raised.

Demonstrating Each Priority Level

class DataLevel:
def __get__(self, obj, objtype=None):
return "PRIORITY 1: data descriptor"
def __set__(self, obj, value):
pass

class NonDataLevel:
def __get__(self, obj, objtype=None):
return "PRIORITY 3: non-data descriptor"

class Levels:
attr = DataLevel()

l = Levels()
l.__dict__["attr"] = "PRIORITY 2: instance __dict__"
print(l.attr)
# "PRIORITY 1: data descriptor" - data descriptor beats instance __dict__

# Now remove the data descriptor and replace with non-data
Levels.attr = NonDataLevel()
print(l.attr)
# "PRIORITY 2: instance __dict__" - instance __dict__ beats non-data descriptor

# Remove instance entry
del l.__dict__["attr"]
print(l.attr)
# "PRIORITY 3: non-data descriptor" - non-data descriptor used as fallback

Class-Level Access Is Different

When you access an attribute on the class itself (MyClass.attr rather than instance.attr), Python uses type.__getattribute__, which works on the metaclass level. The algorithm is:

  1. Search the metaclass MRO for a data descriptor
  2. Check the class __dict__ (and its MRO)
  3. Search the metaclass MRO for a non-data descriptor

For descriptors found in the class __dict__, __get__ is still called, but with obj=None:

class Desc:
def __get__(self, obj, objtype=None):
if obj is None:
return f"accessed on class {objtype.__name__}"
return f"accessed on instance of {objtype.__name__}"

class MyClass:
x = Desc()

print(MyClass.x) # "accessed on class MyClass"
print(MyClass().x) # "accessed on instance of MyClass"

The __getattr__ Fallback

If the full lookup algorithm produces AttributeError, Python checks whether the class defines __getattr__. If it does, that method is called as a last resort:

class Fallback:
def __getattr__(self, name):
return f"fallback for {name}"

f = Fallback()
print(f.anything) # "fallback for anything"
print(f.whatever) # "fallback for whatever"

Do not confuse __getattr__ (fallback only) with __getattribute__ (called on every access). Overriding __getattribute__ replaces the entire lookup algorithm. Overriding __getattr__ merely adds a fallback at the end.

Part 4 - How property Works Under the Hood

property is not special syntax. It is a class that implements the full data descriptor protocol. Here is a faithful reimplementation:

class Property:
"""A reimplementation of the built-in property descriptor."""

def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
if doc is None and fget is not None:
doc = fget.__doc__
self.__doc__ = doc

def __get__(self, obj, objtype=None):
if obj is None:
# Accessed on the class - return the descriptor itself
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)

def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)

def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)

def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)

def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)

def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)

Let us verify that this works identically to the built-in:

class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius

@Property
def celsius(self):
"""Temperature in Celsius."""
return self._celsius

@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value

@Property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32

t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
t.celsius = 0
print(t.fahrenheit) # 32.0
t.celsius = -300 # ValueError: Temperature below absolute zero

Why property Is a Data Descriptor

property always defines __set__ - even for read-only properties, where __set__ raises AttributeError. This is essential. If property were a non-data descriptor, you could accidentally shadow it:

# Hypothetical broken property that is non-data:
class BrokenProperty:
def __init__(self, fget):
self.fget = fget
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.fget(obj)
# No __set__ defined - non-data descriptor!

class Account:
@BrokenProperty
def balance(self):
return self._balance

a = Account()
a._balance = 100
print(a.balance) # 100 - works
a.balance = 999 # This writes to instance __dict__ - silent corruption!
print(a.balance) # 999 - no longer calls the getter!
tip

The getter, setter, and deleter methods on property return new Property instances. They do not mutate the existing one. This is why the @celsius.setter decorator pattern works - it creates a new property that replaces the old one in the class namespace during class construction.

Part 5 - How classmethod and staticmethod Work

Both are descriptors. Neither is magic syntax. Let us reimplement them.

Reimplementing classmethod

class ClassMethod:
"""A reimplementation of the built-in classmethod descriptor."""

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

def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)

def bound_class_method(*args, **kwargs):
return self.func(objtype, *args, **kwargs)

# Preserve metadata
bound_class_method.__func__ = self.func
bound_class_method.__self__ = objtype
return bound_class_method

In practice:

class Registry:
_items = []

@ClassMethod
def register(cls, item):
cls._items.append(item)
print(f"Registered {item} in {cls.__name__}")

@ClassMethod
def all(cls):
return list(cls._items)

Registry.register("plugin_a") # Registered plugin_a in Registry
Registry().register("plugin_b") # Registered plugin_b in Registry
print(Registry.all()) # ['plugin_a', 'plugin_b']

The key mechanism: ClassMethod.__get__ ignores obj (the instance) and binds the function to objtype (the class). When you call Registry.register(...), Python calls ClassMethod.__get__(None, Registry), which returns a callable that prepends Registry as the first argument.

Reimplementing staticmethod

class StaticMethod:
"""A reimplementation of the built-in staticmethod descriptor."""

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

def __get__(self, obj, objtype=None):
return self.func

That is the entire implementation. staticmethod is a descriptor that returns the raw function, stripping away the binding behavior that normal functions have.

class MathUtils:
@StaticMethod
def add(a, b):
return a + b

print(MathUtils.add(3, 4)) # 7
print(MathUtils().add(3, 4)) # 7

Comparison Table

Descriptor__get__ returnsobj.method() receivesClass.method() receives
Function (regular method)Bound method with obj(self, ...)Unbound function - must pass instance manually
classmethodBound callable with cls(cls, ...)(cls, ...)
staticmethodRaw function(...)(...)
note

Since Python 3.10, classmethod and staticmethod can wrap other descriptors, including each other. @classmethod on top of @property was not supported before 3.10 (it was deprecated in 3.11 and removed in 3.13 for classmethod stacking on property). In practice, you rarely need this - but it shows how deeply descriptors compose.

Part 6 - How Bound Methods Work

This is the most underappreciated descriptor in Python: regular functions are descriptors.

Every function object defines __get__. When you access a function through an instance, __get__ returns a bound method - an object that wraps the function with the instance pre-filled as the first argument.

class Dog:
def bark(self):
return f"{self.name} says woof"

# 'bark' is a function object stored in Dog.__dict__
print(type(Dog.__dict__["bark"]))
# <class 'function'>

# Functions have __get__
print(hasattr(Dog.__dict__["bark"], "__get__"))
# True

# Accessing through an instance triggers __get__
d = Dog()
d.name = "Rex"
bound = d.bark
print(type(bound))
# <class 'method'>

print(bound.__self__) # <__main__.Dog object at 0x...> - the instance
print(bound.__func__) # <function Dog.bark at 0x...> - the original function

print(bound()) # "Rex says woof"

What function.__get__ Does

Here is the equivalent logic:

class Function:
"""Pseudocode for how function.__get__ works."""

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

def __get__(self, obj, objtype=None):
if obj is None:
# Accessed on the class - return the function itself
return self
# Accessed on an instance - return a bound method
return BoundMethod(self, obj)

def __call__(self, *args, **kwargs):
return self.code(*args, **kwargs)


class BoundMethod:
"""Pseudocode for a bound method."""

def __init__(self, func, instance):
self.__func__ = func
self.__self__ = instance

def __call__(self, *args, **kwargs):
return self.__func__(self.__self__, *args, **kwargs)

Why Functions Are Non-Data Descriptors

Functions do not define __set__. This means you can shadow a method on a per-instance basis:

class Greeter:
def greet(self):
return "Hello from the class method"

g = Greeter()
print(g.greet()) # "Hello from the class method"

# Shadow the method on this specific instance
g.greet = lambda: "Hello from the instance"
print(g.greet()) # "Hello from the instance"

# Other instances are unaffected
g2 = Greeter()
print(g2.greet()) # "Hello from the class method"

This is by design. Monkey-patching a single instance's method is a legitimate (if rarely needed) pattern in Python. It works because functions are non-data descriptors, so instance __dict__ entries take priority.

Part 7 - Building Validator Descriptors

Descriptors become genuinely powerful when you use them to build reusable validation logic. This is exactly what Django model fields, SQLAlchemy columns, and Pydantic fields do under the hood.

A Basic Typed Field

class Typed:
"""A descriptor that enforces a specific type."""

def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type

def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)

def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value

def __delete__(self, obj):
del obj.__dict__[self.name]
class Person:
name = Typed("name", str)
age = Typed("age", int)

def __init__(self, name, age):
self.name = name # Calls Typed.__set__
self.age = age # Calls Typed.__set__

p = Person("Alice", 30)
print(p.name) # "Alice"
print(p.age) # 30
p.age = "thirty" # TypeError: age must be int, got str

Notice the storage strategy: we store the actual value in obj.__dict__[self.name]. Because Typed is a data descriptor (it defines __set__), the descriptor always intercepts access first, so there is no risk of the __dict__ entry shadowing it.

A Composable Validator Framework

Real frameworks like Pydantic chain multiple validations. Here is a pattern that supports that:

class Validator:
"""Base class for descriptor-based validators."""

def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)

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):
self.validate(value)
setattr(obj, self.private_name, value)

def validate(self, value):
"""Override in subclasses to add validation logic."""
pass


class TypeChecked(Validator):
def __init__(self, expected_type):
self.expected_type = expected_type

def validate(self, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.public_name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)


class RangeChecked(Validator):
def __init__(self, minimum=None, maximum=None):
self.minimum = minimum
self.maximum = maximum

def validate(self, value):
if self.minimum is not None and value < self.minimum:
raise ValueError(
f"{self.public_name} must be >= {self.minimum}, got {value}"
)
if self.maximum is not None and value > self.maximum:
raise ValueError(
f"{self.public_name} must be <= {self.maximum}, got {value}"
)


class StringConstrained(Validator):
def __init__(self, min_length=0, max_length=None, pattern=None):
self.min_length = min_length
self.max_length = max_length
self.pattern = pattern

def validate(self, value):
if not isinstance(value, str):
raise TypeError(f"{self.public_name} must be str, got {type(value).__name__}")
if len(value) < self.min_length:
raise ValueError(
f"{self.public_name} must be at least {self.min_length} characters"
)
if self.max_length is not None and len(value) > self.max_length:
raise ValueError(
f"{self.public_name} must be at most {self.max_length} characters"
)
if self.pattern is not None:
import re
if not re.match(self.pattern, value):
raise ValueError(
f"{self.public_name} must match pattern {self.pattern}"
)

Now put it all together:

class Employee:
name = StringConstrained(min_length=1, max_length=100)
age = RangeChecked(minimum=18, maximum=150)
email = StringConstrained(pattern=r"^[\w.+-]+@[\w-]+\.[\w.-]+$")
salary = RangeChecked(minimum=0)

def __init__(self, name, age, email, salary):
self.name = name
self.age = age
self.email = email
self.salary = salary

# Valid
e = Employee("Alice", 30, "[email protected]", 85000)
print(e.name) # "Alice"
print(e.age) # 30

# Invalid
Employee("", 30, "[email protected]", 85000)
# ValueError: name must be at least 1 characters

Employee("Alice", 15, "[email protected]", 85000)
# ValueError: age must be >= 18, got 15

Employee("Alice", 30, "not-an-email", 85000)
# ValueError: email must match pattern ^[\w.+-]+@[\w-]+\.[\w.-]+$

Employee("Alice", 30, "[email protected]", -1000)
# ValueError: salary must be >= 0, got -1000

Stacking Multiple Validators

For more complex validation, you can compose validators:

class MultiValidated(Validator):
"""A descriptor that chains multiple validation functions."""

def __init__(self, *validators):
self._validators = validators

def validate(self, value):
for v in self._validators:
v(self.public_name, value)

def is_type(expected):
def check(name, value):
if not isinstance(value, expected):
raise TypeError(f"{name}: expected {expected.__name__}, got {type(value).__name__}")
return check

def in_range(lo, hi):
def check(name, value):
if not (lo <= value <= hi):
raise ValueError(f"{name}: {value} not in range [{lo}, {hi}]")
return check

def not_empty(name, value):
if not value:
raise ValueError(f"{name}: must not be empty")


class Product:
name = MultiValidated(is_type(str), not_empty)
price = MultiValidated(is_type((int, float)), in_range(0, 1_000_000))
quantity = MultiValidated(is_type(int), in_range(0, 999_999))

def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity

p = Product("Widget", 19.99, 100)
print(p.name, p.price, p.quantity)
# Widget 19.99 100

Framework Connections

This exact pattern is the foundation of major Python frameworks:

Django model fields are descriptors. CharField, IntegerField, etc., each implement the descriptor protocol to validate and transform data on read/write. Django's DeferredAttribute is the actual descriptor class that wraps model fields.

SQLAlchemy columns use InstrumentedAttribute, a descriptor that intercepts attribute access to track changes, lazily load data, and manage the identity map.

Pydantic fields (v2) use descriptors internally. The FieldInfo objects participate in attribute access to perform validation, coercion, and serialization.

Part 8 - Descriptor + __set_name__ Interaction

Look back at the Typed descriptor from earlier:

class Person:
name = Typed("name", str) # We had to pass "name" as a string
age = Typed("age", int) # And "age" here - redundant!

The attribute name appears twice: once as the Python variable name and once as a string argument. This was a persistent annoyance in Python before 3.6. Every descriptor framework had to work around it with metaclasses, decorators, or convention-based hacks.

The Pre-3.6 Workarounds

Metaclass approach (Django style):

class ModelMeta(type):
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
for attr_name, attr_value in namespace.items():
if isinstance(attr_value, Typed):
attr_value.name = attr_name # Inject the name
return cls

class Model(metaclass=ModelMeta):
pass

class Person(Model):
name = Typed(str) # No need to pass "name" - metaclass handles it
age = Typed(int)

This worked but required a metaclass, which introduced complexity and conflicts when mixing with other metaclass-based frameworks.

The Python 3.6+ Solution: __set_name__

Python 3.6 added __set_name__, a hook that type.__init__ calls automatically on every descriptor found in the class namespace during class creation:

class Typed:
def __init__(self, expected_type):
self.expected_type = expected_type
# name will be set by __set_name__

def __set_name__(self, owner, name):
"""Called by type.__init__ during class creation.

Args:
owner: The class that owns this descriptor
name: The attribute name this descriptor was assigned to
"""
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):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.public_name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.private_name, value)


class Person:
name = Typed(str) # __set_name__ called with owner=Person, name="name"
age = Typed(int) # __set_name__ called with owner=Person, name="age"

def __init__(self, name, age):
self.name = name
self.age = age

p = Person("Alice", 30)
print(p.name) # "Alice"
print(p.age) # 30

No metaclass. No name duplication. The descriptor learns its own name automatically.

When __set_name__ Is Called

__set_name__ is called during class body execution, specifically inside type.__init__. The sequence is:

class LifecycleDescriptor:
def __init__(self):
print(f" __init__: descriptor created")

def __set_name__(self, owner, name):
print(f" __set_name__: owner={owner.__name__}, name={name}")

def __get__(self, obj, objtype=None):
return "value"

print("Before class creation")

class Example:
print(" Class body executing")
field = LifecycleDescriptor()
print(" Class body done")

print("After class creation")

Output:

Before class creation
Class body executing
__init__: descriptor created
Class body done
__set_name__: owner=Example, name=field
After class creation

Notice that __set_name__ is called after the entire class body has executed - during type.__init__, not during class body execution.

Using __set_name__ for Storage Key Management

A common pattern uses __set_name__ to create unique private storage keys:

class CachedProperty:
"""A descriptor that computes a value once and caches it on the instance."""

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

def __set_name__(self, owner, name):
self.attr_name = name
self.cache_name = f"_cached_{name}"

def __get__(self, obj, objtype=None):
if obj is None:
return self
try:
return obj.__dict__[self.cache_name]
except KeyError:
value = self.func(obj)
obj.__dict__[self.cache_name] = value
return value

def __set__(self, obj, value):
# Allow cache invalidation by direct assignment
obj.__dict__[self.cache_name] = value


class DataPipeline:
def __init__(self, raw_data):
self.raw_data = raw_data

@CachedProperty
def processed(self):
print(" Computing processed data (expensive)...")
return [x * 2 for x in self.raw_data]

@CachedProperty
def summary(self):
print(" Computing summary (expensive)...")
return sum(self.processed) / len(self.processed)

dp = DataPipeline([1, 2, 3, 4, 5])

print(dp.processed)
# Computing processed data (expensive)...
# [2, 4, 6, 8, 10]

print(dp.processed)
# [2, 4, 6, 8, 10] - cached, no recomputation

print(dp.summary)
# Computing summary (expensive)...
# 6.0
tip

Python 3.8 added functools.cached_property, which is essentially this pattern built into the standard library. However, functools.cached_property is a non-data descriptor (no __set__), which means it can be inadvertently shadowed. The version above is a data descriptor, giving you more control over cache invalidation.

__set_name__ Without Being a Descriptor

An object does not need to be a descriptor to use __set_name__. Any object in a class namespace that has __set_name__ will have it called during class creation. This is useful for non-descriptor objects that still need to know their attribute name:

class FieldTracker:
"""Not a descriptor - no __get__, __set__, or __delete__."""

registry = []

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

def __set_name__(self, owner, name):
self.name = name
self.owner = owner
FieldTracker.registry.append((owner.__name__, name, self.field_type))

class Schema:
id = FieldTracker("integer")
title = FieldTracker("string")
active = FieldTracker("boolean")

print(FieldTracker.registry)
# [('Schema', 'id', 'integer'), ('Schema', 'title', 'string'), ('Schema', 'active', 'boolean')]

Key Takeaways

  • A descriptor is any object with __get__, __set__, or __delete__ that lives on a class. The protocol is implicit - no registration or base class required.

  • Data descriptors (with __set__ or __delete__) beat instance __dict__. Non-data descriptors (only __get__) lose to instance __dict__. This single rule explains why property intercepts writes, why methods can be shadowed, and why staticmethod works.

  • The attribute lookup order is: data descriptor in MRO, then instance __dict__, then non-data descriptor in MRO. Every dot access in Python follows this algorithm. Knowing it eliminates guesswork.

  • property, classmethod, staticmethod, and bound methods are all descriptors. There is no special compiler magic - they all implement the same three-method protocol you can implement yourself.

  • Functions are non-data descriptors. function.__get__(instance, owner) returns a bound method. This is the mechanism that makes self work.

  • Validator descriptors are the foundation of Django fields, SQLAlchemy columns, and Pydantic fields. The pattern of storing values in instance.__dict__ while validating through a descriptor is ubiquitous in production Python.

  • __set_name__ (Python 3.6+) eliminated the need for metaclass-based descriptor naming. Descriptors now automatically learn their attribute name during class creation, making them self-contained and composable.

  • When designing descriptors, decide early: data or non-data. Data descriptors provide strict control (validation, computed attributes). Non-data descriptors provide flexibility (caching, lazy loading, shadowable behavior).

Graded Practice Challenges

Level 1 - Predict the Output

For each snippet, predict the output before checking the answer.

Question 1:

class D:
def __get__(self, obj, objtype=None):
return "descriptor"

class C:
x = D()

c = C()
c.__dict__["x"] = "instance"
print(c.x)
Answer
instance

D only defines __get__, making it a non-data descriptor. Instance __dict__ takes priority over non-data descriptors.

Question 2:

class D:
def __get__(self, obj, objtype=None):
return "descriptor"
def __set__(self, obj, value):
obj.__dict__["x"] = value * 2

class C:
x = D()

c = C()
c.x = 5
print(c.__dict__["x"])
print(c.x)
Answer
10
descriptor

c.x = 5 calls D.__set__, which stores 10 in c.__dict__["x"]. But c.x still calls D.__get__ (because D is a data descriptor and takes priority over instance __dict__), which returns "descriptor". The value 10 is in the __dict__ but is never seen through normal attribute access.

Question 3:

class D:
def __get__(self, obj, objtype=None):
if obj is None:
return "class access"
return "instance access"

class C:
x = D()

print(C.x)
print(C().x)
print(C.__dict__["x"].__get__(None, C))
Answer
class access
instance access
class access

Accessing on the class passes obj=None. Accessing on an instance passes the instance. Calling __get__ manually with None mimics class access.

Question 4:

class C:
def method(self):
return "original"

c = C()
print(type(C.__dict__["method"]))
print(type(c.method))
c.method = lambda: "replaced"
print(c.method())
del c.method
print(c.method())
Answer
<class 'function'>
<class 'method'>
replaced
original

C.__dict__["method"] is a function. c.method triggers function.__get__, producing a bound method. Assigning to c.method writes to instance __dict__ (functions are non-data descriptors, so this shadows the class method). Deleting removes the shadow, restoring access to the class-level function.

Question 5:

class AutoNamed:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
return self.name

class Example:
foo = AutoNamed()
bar = AutoNamed()

e = Example()
print(e.foo)
print(e.bar)
print(Example.bar)
Answer
foo
bar
bar

__set_name__ stores the attribute name. __get__ returns it regardless of whether obj is None or not (there is no if obj is None guard). So both instance and class access return the name.

Level 2 - Debug Challenge

The following code is supposed to implement a PositiveInt descriptor that only allows positive integers. However, it has a bug that causes values to silently disappear under certain conditions. Find and fix the bug.

class PositiveInt:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.value

def __set__(self, obj, value):
if not isinstance(value, int) or value <= 0:
raise ValueError(f"Expected positive int, got {value}")
self.value = value


class Config:
timeout = PositiveInt()
retries = PositiveInt()

def __init__(self, timeout, retries):
self.timeout = timeout
self.retries = retries


c1 = Config(30, 3)
c2 = Config(60, 5)

print(c1.timeout) # Expected: 30, Actual: ???
print(c1.retries) # Expected: 3, Actual: ???
Hint

Where is self.value stored? Is self the descriptor instance or the object instance? How many descriptor instances exist?

Solution

The bug: self.value stores the value on the descriptor instance, not on the object instance. Since there is one PositiveInt() descriptor per class attribute (not per instance), all instances share the same stored value. c2 overwrites what c1 stored.

Additionally, timeout and retries share separate descriptor instances, but all Config instances share the same timeout descriptor, so c1.timeout and c2.timeout overwrite each other.

Output:

60
5

Both values come from c2's __init__, because it ran second and overwrote the descriptor's self.value.

Fix: Store values on the object instance, not the descriptor:

class PositiveInt:
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)

def __set__(self, obj, value):
if not isinstance(value, int) or value <= 0:
raise ValueError(f"Expected positive int, got {value}")
obj.__dict__[self.name] = value

Now each object stores its own value in its own __dict__, and the descriptor merely validates and routes access.

Level 3 - Design Challenge

Design and implement a LazyDB descriptor that simulates lazy-loading from a database. Requirements:

  1. On first access (__get__), it should print "Loading {name} from database...", compute a value by calling a provided factory function, cache the result on the instance, and return it.
  2. On subsequent accesses, it should return the cached value without printing or recomputing.
  3. Assigning to the attribute (__set__) should update the cached value and print "Marking {name} as dirty".
  4. Deleting the attribute (__delete__) should clear the cache and print "Evicting {name} from cache", so the next access triggers a reload.
  5. It must use __set_name__ for automatic naming.
  6. It must work correctly with multiple instances and multiple fields.

Your implementation should pass this test:

class UserProfile:
bio = LazyDB(lambda self: f"Bio for user {self.user_id}")
settings = LazyDB(lambda self: {"theme": "dark", "user": self.user_id})

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

u1 = UserProfile(1)
u2 = UserProfile(2)

print(u1.bio) # Loading bio from database... -> "Bio for user 1"
print(u1.bio) # "Bio for user 1" (no loading message)
print(u2.bio) # Loading bio from database... -> "Bio for user 2"

u1.bio = "Custom bio" # Marking bio as dirty
print(u1.bio) # "Custom bio"

del u1.bio # Evicting bio from cache
print(u1.bio) # Loading bio from database... -> "Bio for user 1"
Solution
class LazyDB:
def __init__(self, factory):
self.factory = factory

def __set_name__(self, owner, name):
self.public_name = name
self.cache_key = f"_lazydb_{name}"

def __get__(self, obj, objtype=None):
if obj is None:
return self
try:
return obj.__dict__[self.cache_key]
except KeyError:
print(f"Loading {self.public_name} from database...")
value = self.factory(obj)
obj.__dict__[self.cache_key] = value
return value

def __set__(self, obj, value):
print(f"Marking {self.public_name} as dirty")
obj.__dict__[self.cache_key] = value

def __delete__(self, obj):
print(f"Evicting {self.public_name} from cache")
obj.__dict__.pop(self.cache_key, None)

Key design decisions:

  • Data descriptor: Defines both __get__ and __set__ (and __delete__), so it always controls access. Instance __dict__ entries under self.cache_key will not shadow it because the lookup goes through the descriptor first.
  • Private cache key: Uses _lazydb_{name} to avoid colliding with user-defined attributes.
  • Factory receives self: The factory function gets the instance, so it can compute values based on instance state (like self.user_id).
  • __set_name__: No need to pass the name manually.

What's Next

In the next lesson, 03 - __init_subclass__, we explore Python's built-in hook for intercepting subclass creation. Where descriptors control attribute access on instances, __init_subclass__ controls what happens when a class is subclassed - enabling plugin registration, automatic validation of subclass structure, and configuration injection, all without metaclasses.

© 2026 EngineersOfAI. All rights reserved.