__get__, __set__, __delete__ protocol, data vs. non-data descriptors, and property/classmethod/staticmethod internals.
A descriptor is an object that controls attribute access on another object. Descriptors are one of the main mechanisms behind Python’s object model. They explain how methods bind to instances, how property works, how staticmethod and classmethod work, how slots work, and how many CPython-level type operations connect to Python-level syntax.
At the language level, a descriptor is any object that defines one or more of these methods:
__get__(self, obj, objtype=None)
__set__(self, obj, value)
__delete__(self, obj)An object with __get__ is a descriptor.
An object with __set__ or __delete__ is a data descriptor.
The distinction between non-data descriptors and data descriptors controls lookup precedence.
43.1 Why Descriptors Exist
Python attribute access looks simple:
obj.nameBut this expression does not mean “read a field named name from memory.”
It means:
ask the object's type how attribute lookup works
search descriptors and dictionaries in a defined order
possibly call descriptor methods
return the resulting objectDescriptors make attribute access programmable. They allow one object to mediate access to another object’s attribute.
Examples:
obj.method
obj.property_name
Class.class_method
Class.static_method
obj.slot_nameAll of these involve descriptor behavior.
43.2 Basic Descriptor Protocol
A descriptor defines __get__.
class Descriptor:
def __get__(self, obj, objtype=None):
return "computed value"Use it as a class attribute:
class Example:
value = Descriptor()
e = Example()
print(e.value)Output:
computed valueThe descriptor object is stored on the class:
print(Example.__dict__["value"])But attribute access through the instance calls Descriptor.__get__.
43.3 Descriptor Arguments
The descriptor method receives:
| Argument | Meaning |
|---|---|
self | The descriptor object |
obj | The instance being accessed, or None for class access |
objtype | The owner class |
Example:
class Descriptor:
def __get__(self, obj, objtype=None):
print("obj:", obj)
print("objtype:", objtype)
return 42
class Example:
value = Descriptor()
e = Example()
print(e.value)
print(Example.value)For instance access:
obj: <__main__.Example object at ...>
objtype: <class '__main__.Example'>For class access:
obj: None
objtype: <class '__main__.Example'>A descriptor can use this difference to return different values for instance and class access.
43.4 Non-Data Descriptors
A non-data descriptor defines __get__, but not __set__ or __delete__.
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
return "descriptor value"Example:
class Example:
value = NonDataDescriptor()
e = Example()
print(e.value)Output:
descriptor valueBut because it is a non-data descriptor, an instance dictionary entry can override it:
e.__dict__["value"] = "instance value"
print(e.value)Output:
instance valueThis precedence is intentional. It is how normal methods can be shadowed by instance attributes.
43.5 Data Descriptors
A data descriptor defines __set__ or __delete__.
class DataDescriptor:
def __get__(self, obj, objtype=None):
return "descriptor value"
def __set__(self, obj, value):
print("setting", value)Example:
class Example:
value = DataDescriptor()
e = Example()
e.__dict__["value"] = "instance value"
print(e.value)Output:
descriptor valueThe data descriptor wins over the instance dictionary.
This rule is critical for property.
43.6 Lookup Precedence
For normal instance attribute access:
obj.nameCPython’s object lookup roughly follows this order:
1. Look for name on the type or base types.
2. If found and it is a data descriptor, call its __get__.
3. Look in the instance dictionary.
4. If found on the type and it is a non-data descriptor, call its __get__.
5. If found on the type as a normal attribute, return it.
6. If not found, call __getattr__ if defined.
7. Otherwise raise AttributeError.This order explains why some class attributes can be shadowed by instance attributes and others cannot.
43.7 Descriptor Precedence Example
class NonData:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class Data:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
raise AttributeError("read-only")
class Example:
a = NonData()
b = Data()
e = Example()
e.__dict__["a"] = "instance a"
e.__dict__["b"] = "instance b"
print(e.a)
print(e.b)Output:
instance a
data descriptora is shadowed because it is a non-data descriptor.
b is not shadowed because it is a data descriptor.
43.8 Functions Are Descriptors
Functions stored on classes are descriptors.
class Example:
def method(self):
return 42
e = Example()
print(Example.__dict__["method"])
print(e.method)The object stored in the class dictionary is a function object.
The object returned by instance access is a bound method.
function object stored on class
↓ __get__(instance, class)
bound method objectThis is why:
e.method()passes e as the first argument.
43.9 Method Binding
A function’s descriptor behavior can be modeled like this:
class FunctionLike:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return BoundMethod(self, obj)The bound method stores:
function
instanceCalling the bound method:
e.method(1, 2)is roughly equivalent to:
Example.method(e, 1, 2)This is not special syntax in the parser. It is ordinary attribute lookup plus descriptor binding plus call.
43.10 Class Access to Methods
When accessed through the class, a function descriptor receives obj=None.
class Example:
def method(self):
return 42
print(Example.method)The result is the original function-like object, not a bound method to an instance.
So this works:
e = Example()
print(Example.method(e))The instance is passed explicitly.
43.11 Bound Method Objects
A bound method object exposes useful attributes:
class Example:
def method(self):
return 42
e = Example()
m = e.method
print(m.__self__)
print(m.__func__)__self__ is the bound instance.
__func__ is the underlying function.
Conceptually:
bound_method.__self__ -> e
bound_method.__func__ -> Example.__dict__["method"]Calling:
m()calls:
m.__func__(m.__self__)43.12 property
property is a data descriptor.
class User:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._nameAccess:
u = User("Ada")
print(u.name)calls the property’s getter.
A rough model:
class property:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)Because property defines __set__, it is a data descriptor. It wins over the instance dictionary.
43.13 Read-Only Properties
A read-only property still acts like a data descriptor.
class Example:
@property
def value(self):
return 42
e = Example()
e.__dict__["value"] = 100
print(e.value)Output:
42Even though the property has no setter, the property type has __set__ behavior that raises an error. That makes it a data descriptor.
43.14 Writable Properties
A writable property adds a setter.
class User:
def __init__(self):
self._name = ""
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not value:
raise ValueError("empty name")
self._name = valueUsage:
u = User()
u.name = "Ada"
print(u.name)Assignment:
u.name = "Ada"calls the descriptor’s __set__.
It does not simply write u.__dict__["name"].
43.15 staticmethod
staticmethod is a descriptor that suppresses method binding.
class Math:
@staticmethod
def add(a, b):
return a + b
print(Math.add(2, 3))
print(Math().add(2, 3))In both cases, no instance is inserted as the first argument.
A rough model:
class staticmethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
return self.funcstaticmethod stores a function and returns it unchanged.
43.16 classmethod
classmethod is a descriptor that binds the class instead of the instance.
class User:
@classmethod
def create(cls):
return cls()
u = User.create()A rough model:
class classmethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)
return BoundMethod(self.func, objtype)Calling:
User.create()is roughly equivalent to:
User.__dict__["create"].__get__(None, User)()which calls:
original_function(User)43.17 __slots__ and Descriptors
__slots__ uses descriptors to manage fixed-layout instance attributes.
Example:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = yThe class dictionary contains slot descriptors:
print(Point.__dict__["x"])
print(Point.__dict__["y"])These descriptors know how to read and write values from fixed offsets in the instance layout.
With slots, normal instances may not have __dict__ unless explicitly requested.
p = Point(1, 2)
print(hasattr(p, "__dict__"))Output:
False43.18 Member Descriptors and Getset Descriptors
CPython exposes some C-level descriptors as objects.
Common examples:
member_descriptor
getset_descriptor
wrapper_descriptor
method_descriptorYou can see them in class dictionaries:
class Point:
__slots__ = ("x",)
print(type(Point.__dict__["x"]))For built-in types:
print(type(int.__dict__["real"]))
print(type(str.__dict__["upper"]))
print(type(object.__dict__["__str__"]))These are CPython-level descriptor objects wrapping C-level behavior.
43.19 Descriptor and Attribute Assignment
For assignment:
obj.name = valueCPython does not always write into obj.__dict__.
If the type has a data descriptor for name, assignment calls the descriptor’s __set__.
Example:
class Descriptor:
def __set__(self, obj, value):
print("set", value)
class Example:
x = Descriptor()
e = Example()
e.x = 10Output:
set 10No normal instance dictionary write is required.
43.20 Descriptor and Attribute Deletion
For deletion:
del obj.nameIf a data descriptor defines __delete__, CPython calls it.
class Descriptor:
def __delete__(self, obj):
print("delete")
class Example:
x = Descriptor()
e = Example()
del e.xOutput:
deleteOtherwise, deletion may remove an entry from the instance dictionary or raise AttributeError.
43.21 __set_name__
Descriptors can define __set_name__.
class Field:
def __set_name__(self, owner, name):
self.owner = owner
self.name = name
class User:
id = Field()
name = Field()During class creation, Python calls:
User.__dict__["id"].__set_name__(User, "id")
User.__dict__["name"].__set_name__(User, "name")This lets descriptors learn the attribute name they were assigned to.
Without __set_name__, descriptors often require redundant configuration:
id = Field("id")
name = Field("name")43.22 Practical Validating 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 getattr(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError("expected int")
if value <= 0:
raise ValueError("expected positive integer")
setattr(obj, self.name, value)Usage:
class User:
age = PositiveInt()
def __init__(self, age):
self.age = age
u = User(30)
print(u.age)The assignment:
self.age = agecalls PositiveInt.__set__.
The access:
u.agecalls PositiveInt.__get__.
43.23 Descriptor Storage Choices
Descriptors usually store per-instance values somewhere else.
Common choices:
| Storage | Example | Tradeoff |
|---|---|---|
| Instance dictionary | obj.__dict__[name] | Simple, requires __dict__ |
| Private attribute | obj._name | Simple, can collide |
| Weak key dictionary | WeakKeyDictionary[obj] | Works without touching instance dict, higher overhead |
| Slot offset | CPython internal | Fast, fixed layout |
| External store | ORM/session/state table | Useful for frameworks |
A descriptor object is usually shared by the class, so storing instance-specific data directly on the descriptor is usually wrong.
Bad:
class BadField:
def __set__(self, obj, value):
self.value = valueAll instances share one descriptor object.
Better:
class Field:
def __set_name__(self, owner, name):
self.name = "_" + name
def __set__(self, obj, value):
setattr(obj, self.name, value)43.24 Descriptor Sharing
A descriptor is stored on the class.
class Field:
pass
class User:
name = Field()There is one Field object for User.name.
All User instances access the same descriptor object.
print(User.__dict__["name"])This is why descriptor state must be designed carefully.
Descriptor-level state is good for:
field name
validation rule
default configuration
metadata
owner classInstance-level state belongs on the instance or in an external per-instance store.
43.25 Descriptors and Inheritance
Descriptors are inherited like other class attributes.
class Field:
def __get__(self, obj, objtype=None):
return "value"
class Base:
x = Field()
class Child(Base):
pass
print(Child().x)Output:
valueLookup searches the method resolution order. If a descriptor is found in a base class, it participates in attribute access for child instances.
A subclass can override the descriptor by defining the same name.
class Child(Base):
x = 100Now:
print(Child().x)returns:
100unless other lookup rules apply.
43.26 Descriptors and super
super() also uses descriptor binding.
Example:
class Base:
def method(self):
return "base"
class Child(Base):
def method(self):
return super().method()The expression:
super().methodfinds method on the next class in the MRO and binds it to the original instance.
That binding still uses descriptor logic.
43.27 Descriptors and __getattribute__
All normal attribute access goes through __getattribute__.
obj.namecalls roughly:
type(obj).__getattribute__(obj, "name")The default implementation, object.__getattribute__, implements descriptor lookup.
If a class overrides __getattribute__, it can change or bypass descriptor behavior.
Example:
class Example:
@property
def x(self):
return 42
def __getattribute__(self, name):
return "intercepted"
e = Example()
print(e.x)Output:
interceptedThe property is never reached because __getattribute__ intercepts everything.
43.28 Descriptors and __getattr__
__getattr__ is only called after normal lookup fails.
class Example:
def __getattr__(self, name):
return "missing"
e = Example()
print(e.anything)Output:
missingIf a descriptor exists and returns a value, __getattr__ is not called.
Lookup order:
__getattribute__ runs first
descriptor rules happen inside normal __getattribute__
__getattr__ handles missing names only43.29 Descriptors and Metaclasses
Class attribute access uses descriptor logic too, but the object being searched is a class object.
class Meta(type):
@property
def label(cls):
return cls.__name__.lower()
class User(metaclass=Meta):
pass
print(User.label)Here, label is a descriptor on the metaclass. Accessing User.label invokes descriptor lookup on type(User).
This is why metaclasses can define computed class attributes.
43.30 Descriptor Lookup on Classes
For:
Class.attrlookup is handled by type.__getattribute__.
The search happens on the class object and its metaclass.
If an attribute in the metaclass is a descriptor, it may bind with the class as the object.
This is how classmethod, metaclass properties, and many built-in type operations work.
Class lookup and instance lookup are related but not identical. Instance lookup searches the instance type’s MRO. Class lookup searches through metaclass machinery.
43.31 Descriptors in ORMs
Descriptors are common in ORMs.
Example shape:
class Column:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj._row[self.name]
def __set__(self, obj, value):
obj._row[self.name] = valueUsage:
class User:
id = Column()
name = Column()
def __init__(self, row):
self._row = rowThen:
u = User({"id": 1, "name": "Ada"})
print(u.name)
u.name = "Grace"The descriptor maps attribute access to row storage.
43.32 Descriptors in Validation Libraries
Validation frameworks use descriptors to enforce constraints.
class String:
def __set_name__(self, owner, name):
self.storage = "_" + name
def __set__(self, obj, value):
if not isinstance(value, str):
raise TypeError("expected str")
setattr(obj, self.storage, value)
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage)Usage:
class User:
name = String()
def __init__(self, name):
self.name = nameThe class definition declares the field. The descriptor enforces the rule.
43.33 Descriptors in Cached Computation
A cached property is a non-data descriptor.
class cached_property:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = self.func(obj)
obj.__dict__[self.name] = value
return valueUsage:
class Data:
@cached_property
def total(self):
print("computing")
return 100
d = Data()
print(d.total)
print(d.total)First access calls the descriptor and stores the value in the instance dictionary.
Second access returns the instance dictionary value because the descriptor is non-data.
This relies directly on descriptor precedence.
43.34 Data vs Non-Data Design Rule
Use a data descriptor when the descriptor must control writes or must not be overridden by instance attributes.
Use a non-data descriptor when instance-level caching or shadowing is desired.
| Descriptor kind | Defines | Instance dict can override? | Common example |
|---|---|---|---|
| Non-data descriptor | __get__ only | Yes | functions, cached property |
| Data descriptor | __set__ or __delete__ | No | property, slots, validating fields |
This one distinction explains many attribute access behaviors.
43.35 CPython Internal Slots for Descriptors
At the C level, descriptor behavior is represented through type slots.
The relevant operations correspond to:
tp_descr_get
tp_descr_setA type that implements tp_descr_get can act like it has __get__.
A type that implements tp_descr_set can act like it has __set__ or __delete__.
Built-in descriptors are often C objects whose types provide these slots.
This is how built-in methods, slot descriptors, getset descriptors, and wrapper descriptors integrate with normal Python lookup.
43.36 Attribute Lookup in CPython Terms
A simplified CPython-level lookup for obj.name:
1. type = Py_TYPE(obj)
2. descr = lookup name in type MRO
3. if descr has tp_descr_get and tp_descr_set:
return descr.__get__(obj, type)
4. if obj has dict and name in dict:
return dict[name]
5. if descr has tp_descr_get:
return descr.__get__(obj, type)
6. if descr exists:
return descr
7. call fallback or raise AttributeErrorThis is the operational heart of descriptors.
43.37 Descriptor Errors
Descriptors should raise AttributeError for missing attributes when they want normal attribute fallback behavior.
Example:
class Maybe:
def __get__(self, obj, objtype=None):
raise AttributeError("not available")A descriptor that raises AttributeError may interact with getattr, hasattr, and fallback mechanisms.
Raise precise exceptions. Do not hide unrelated errors as AttributeError unless the attribute is genuinely unavailable.
43.38 Descriptor Introspection
To inspect a descriptor, access it through the class dictionary.
class Example:
@property
def value(self):
return 42
print(Example.__dict__["value"])Accessing through the class may call __get__:
print(Example.value)For property, class access returns the property object. But other descriptors may return computed values.
The safest way to retrieve the raw descriptor is usually:
vars(Example)["value"]or:
Example.__dict__["value"]43.39 Common Descriptor Bugs
| Bug | Cause | Fix |
|---|---|---|
| All instances share one value | Stored instance state on descriptor | Store state on instance or external per-instance map |
| Property ignored by custom class | Overrode __getattribute__ incorrectly | Delegate to object.__getattribute__ |
| Cached property does not cache | Descriptor is data descriptor | Make it non-data or write custom precedence logic |
| Instance attribute cannot override field | Descriptor defines __set__ | Use non-data descriptor if override is desired |
| Descriptor lacks field name | Did not implement __set_name__ | Add __set_name__ or pass name explicitly |
| Class access breaks | __get__ does not handle obj is None | Return descriptor or class-level object |
43.40 Key Points
Descriptors are objects that define __get__, __set__, or __delete__.
A non-data descriptor defines __get__ only.
A data descriptor defines __set__ or __delete__.
Data descriptors take precedence over instance dictionaries.
Non-data descriptors can be shadowed by instance dictionaries.
Functions are descriptors, which is why methods bind to instances.
property, staticmethod, classmethod, slots, built-in methods, and many CPython internals use descriptors.
Descriptors are implemented in CPython through descriptor slots on type objects.
Understanding descriptors is required for understanding methods, properties, slots, metaclasses, and attribute lookup.