Descriptors are objects that define __get__, __set__, and/or __delete__ to intercept attribute access
Data descriptors (with __set__/__delete__) always win over instance __dict__
Non-data descriptors (__get__ only) lose to instance __dict__ — makes methods shadowable
__set_name__ (Python 3.6+) auto-captures the attribute name at class creation
Performance cost: ~2-3x slower than direct __dict__ lookup for each access
Biggest mistake: storing per-instance state on the descriptor itself instead of in instance.__dict__
✦ Definition~90s read
What is Python Descriptors?
Python descriptors are objects that define custom behavior for attribute access on other objects by implementing the descriptor protocol: __get__, __set__, and __delete__. They exist to solve a fundamental tension in Python: you want simple attribute syntax (obj.attr) but need control over what happens when that attribute is read, written, or deleted.
★
Imagine every locker in a school has a standard lock, but the principal installs a special locker that buzzes an alarm, logs who opened it, and only lets certain students in.
Without descriptors, you'd be stuck with either plain instance dictionaries (no validation, no computed values) or clunky getter/setter methods that break the clean dot-access pattern. Descriptors are the mechanism that makes property, classmethod, staticmethod, and even super() work under the hood — they're not an esoteric feature but the backbone of Python's attribute system.
In practice, descriptors let you intercept attribute access at the class level, not the instance level. This is where the "missing instance dict shared state bug" bites: if you store state on the descriptor itself (e.g., self._value), that state is shared across all instances of the class because the descriptor object lives on the class, not on each instance.
The fix is to store per-instance data in the instance's __dict__ using the instance's identity (typically via object.__setattr__ or by keying on id(instance)). Production-grade descriptors like SQLAlchemy's Column or Django's Field use this pattern to avoid cross-instance contamination.
Descriptors are not a tool you reach for every day — they're for framework authors and library builders who need to control attribute semantics at scale. If you're writing application code, @property and __slots__ usually suffice. But when you need reusable validation (e.g., "all integers must be positive"), lazy computation with caching, or ORM field descriptors that sync with database columns, you need to understand the protocol deeply.
The gotcha is that Python's attribute lookup chain (data descriptors > instance __dict__ > non-data descriptors > class __dict__) means a single misplaced __set__ can silently break your entire class hierarchy.
Plain-English First
Imagine every locker in a school has a standard lock, but the principal installs a special locker that buzzes an alarm, logs who opened it, and only lets certain students in. That special locker isn't just a container — it has rules baked into the door itself. Python descriptors are exactly that: objects that intercept attribute access on other objects and inject custom behaviour the moment you read, write, or delete a value. They're the mechanism behind property, classmethod, staticmethod, and __slots__ — you've been using them all along without knowing it.
Every seasoned Python developer has written a @property and moved on. Far fewer have asked the obvious next question: how does @property actually work? The answer is descriptors — a protocol sitting at the very heart of Python's object model that lets you control what happens when an attribute is accessed on a class. This isn't an academic curiosity. Django model fields, SQLAlchemy's ORM columns, NumPy's array interface, and pytest fixtures all depend on descriptors for their expressive, magic-looking APIs.
The problem descriptors solve is deceptively simple: attributes are dumb by default. self.temperature = -300 happily stores an impossible value with zero complaint. You could add validation logic directly inside __init__, but that falls apart the moment you have ten classes sharing the same validation rule. Descriptors let you encapsulate attribute behaviour once, in one place, and attach it to as many classes as you like — clean, reusable, and transparent to the caller.
By the end of this article you'll understand the full descriptor protocol (__get__, __set__, __delete__, __set_name__), the crucial difference between data and non-data descriptors and why that difference changes attribute lookup priority, exactly how Python's built-in property, classmethod, and staticmethod are implemented as descriptors, the performance trade-offs to consider before using descriptors in hot paths, and the production-grade patterns that separate a toy descriptor from one you'd ship in a library.
How Python Descriptors Hijack Attribute Access
A descriptor is any Python object that defines __get__, __set__, or __delete__ and lives as a class attribute. When you access an instance attribute, Python checks the class hierarchy for a descriptor first — if found, the descriptor's __get__ runs instead of returning the instance dict value. This is the core mechanic that powers properties, classmethods, staticmethods, and __slots__ under the hood.
Descriptors come in two flavors: data descriptors (define __set__ or __delete__) and non-data descriptors (only __get__). Data descriptors take priority over instance __dict__ entries; non-data descriptors lose to instance attributes. This ordering is the source of the infamous shared state bug: if a descriptor stores state on self (the descriptor instance) rather than per-instance, all instances of the class share that state silently.
Use descriptors when you need reusable attribute logic — validation, computed properties, lazy loading — without repeating boilerplate. They're essential for framework code (Django model fields, SQLAlchemy ORM) where attribute access must trigger side effects. But never store mutable state on the descriptor object itself unless you intend global sharing across all instances.
Shared State Trap
A data descriptor that mutates self (the descriptor instance) affects every object of that class — there's only one descriptor per class, not per instance.
Production Insight
A team built a caching descriptor that stored results in a dict on self. Under load, different request objects returned each other's cached data, causing cross-user data leaks.
Symptom: intermittent 'impossible' data appearing in unrelated user sessions, reproducible only under concurrency.
Rule: if a descriptor needs per-instance state, store it in the instance's __dict__ (e.g., via instance.__dict__[key]) or use a WeakKeyDictionary keyed on the instance.
Key Takeaway
Descriptors override instance __dict__ lookups — data descriptors always win, non-data descriptors lose to instance attributes.
Never store mutable state on the descriptor object itself; it's shared across all instances of the class.
Use descriptors for reusable attribute logic, but prefer @property for single-use cases to avoid over-engineering.
thecodeforge.io
Python Descriptor Protocol Flow
Python Descriptors
The Descriptor Protocol — What Python Actually Does When You Access an Attribute
A descriptor is any object that defines at least one of __get__, __set__, or __delete__. That's the entire entry requirement. When Python resolves instance.attr, it doesn't just rummage through instance.__dict__. It runs a precise lookup algorithm defined in object.__getattribute__.
The algorithm goes like this: first, Python walks the MRO of the instance's type looking for attr in the class namespace. If it finds an object there that defines __get__and (__set__ or __delete__), that object is a data descriptor and it wins unconditionally — even if instance.__dict__ has a same-named key. If the class object only defines __get__ (no __set__ or __delete__), it's a non-data descriptor and the instance __dict__ takes priority. If nothing in the class hierarchy has __get__, Python falls back to the instance __dict__ directly.
This priority order — data descriptor → instance dict → non-data descriptor → class attribute — is the single most important thing to internalise about descriptors. Getting it wrong is responsible for most descriptor bugs in the wild. property is a data descriptor (it defines all three). A plain function is a non-data descriptor (it only defines __get__), which is why instance.method works but you can still shadow it with instance.method = something_else.
descriptor_lookup_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# Demonstrate the data vs non-data descriptor priority differenceclassDataDescriptor:
"""Defines both __get__ and __set__ — always wins over instance dict."""def__set_name__(self, owner_class, attribute_name):
# Called automatically when the class body is processed (Python 3.6+)# Gives us the attribute name without needing to pass it manuallyself.storage_key = f'_dd_{attribute_name}'def__get__(self, instance, owner_class):
if instance isNone:
# Accessed on the class itself, not an instance — return the descriptorreturnselfreturn instance.__dict__.get(self.storage_key, 'NOT SET')
def__set__(self, instance, value):
print(f' [DataDescriptor.__set__] storing {value!r}')
instance.__dict__[self.storage_key] = value
classNonDataDescriptor:
"""Only defines __get__ — instance dict takes priority over this."""def__get__(self, instance, owner_class):
if instance isNone:
returnselfreturn'value from NonDataDescriptor'classExperiment:
data_attr = DataDescriptor() # data descriptor
nondata_attr = NonDataDescriptor() # non-data descriptor
experiment = Experiment()
# --- Data descriptor priority demo ---print('=== Data Descriptor ===')
experiment.data_attr = 'hello' # triggers __set__# Now manually jam a value into __dict__ under the descriptor's storage key# The descriptor stores under '_dd_data_attr', but let's try the PUBLIC name
experiment.__dict__['data_attr'] = 'sneaky direct write'print('instance.__dict__["data_attr"] =', experiment.__dict__.get('data_attr'))
print('experiment.data_attr =', experiment.data_attr) # descriptor still wins# --- Non-data descriptor priority demo ---print('\n=== Non-Data Descriptor ===')
print('Before shadowing:', experiment.nondata_attr) # from descriptor
experiment.__dict__['nondata_attr'] = 'instance dict wins'print('After shadowing: ', experiment.nondata_attr) # instance dict wins now# --- Accessing descriptor on the class (instance=None path) ---print('\n=== Class-level access ===')
print('Experiment.data_attr:', Experiment.data_attr) # returns descriptor itself
Output
=== Data Descriptor ===
[DataDescriptor.__set__] storing 'hello'
instance.__dict__["data_attr"] = 'sneaky direct write'
experiment.data_attr = hello
=== Non-Data Descriptor ===
Before shadowing: value from NonDataDescriptor
After shadowing: instance dict wins
=== Class-level access ===
Experiment.data_attr: <__main__.DataDescriptor object at 0x...>
Watch Out: The 'instance is None' check is non-negotiable
If you forget to check if instance is None in __get__, accessing the descriptor on the class (e.g. MyClass.attr) will crash because Python passes None as the instance. Always return self (or a meaningful class-level value) in that branch.
Production Insight
Data descriptors cannot be shadowed by instance dict writes — that's why @property guards work. Non-data descriptors can be shadowed silently, leading to subtle bugs when a method is accidentally overwritten by an instance attribute. In production, this causes method calls to return unexpected values without raising any error.
Rule: if you need to prevent shadowing, turn it into a data descriptor by adding a trivial __set__ that raises AttributeError.
Key Takeaway
Data descriptor always wins over instance dict.
Non-data descriptor loses to instance dict.
If you want to prevent instance shadowing, make it a data descriptor.
Building a Production-Grade Validated Descriptor with __set_name__
__set_name__ was added in Python 3.6 and it changes everything about how you write reusable descriptors. Before it existed, you had to pass the attribute name as a constructor argument — price = Validated('price', ...) — which was redundant and error-prone. Now Python calls __set_name__(owner, name) automatically during class creation, handing you the exact name the descriptor was assigned to.
The classic mistake beginners make when building descriptors is storing per-instance data on the descriptor itself. Because the descriptor is a class-level object shared by all instances, storing self.value = x inside __set__ means every instance of the class would share the same variable. The correct pattern is to store data in the instance's__dict__ using a mangled key (commonly prefixed with an underscore plus the descriptor's own name).
Below is a complete, reusable TypeValidated descriptor you could drop into any project. It enforces type and optional range constraints, and because it's a class, you can extend it or compose it without touching the classes that use it. Notice how the same descriptor class powers three completely different attributes on WeatherReading.
validated_descriptor.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
from typing importAny, Type, OptionalclassTypeValidated:
"""
A reusable data descriptor that enforces type and optional numeric bounds.
Store one instance as a class attribute; Python handles the per-instance
data automatically via instance.__dict__.
"""
def__init__(
self,
expected_type: Type,
min_value: Optional[float] = None,
max_value: Optional[float] = None,
):
self.expected_type = expected_type
self.min_value = min_value
self.max_value = max_value
self.storage_key = None# filled in by __set_name__def__set_name__(self, owner_class: Type, attribute_name: str):
# Python calls this automatically during class body execution.# We prefix with '_tv_' to avoid colliding with the public name.self.public_name = attribute_name
self.storage_key = f'_tv_{attribute_name}'def__get__(self, instance: Any, owner_class: Type):
if instance isNone:
return self # class-level access returns the descriptor# Retrieve from instance.__dict__; raise AttributeError if not yet settry:
return instance.__dict__[self.storage_key]
exceptKeyError:
raiseAttributeError(
f'{owner_class.__name__}.{self.public_name} has not been set'
)
def__set__(self, instance: Any, value: Any):
# --- Type check ---ifnotisinstance(value, self.expected_type):
raiseTypeError(
f'{self.public_name} expects {self.expected_type.__name__}, '
f'got {type(value).__name__} instead'
)
# --- Range check (only for numeric types) ---ifself.min_value isnotNoneand value < self.min_value:
raiseValueError(
f'{self.public_name} must be >= {self.min_value}, got {value}'
)
ifself.max_value isnotNoneand value > self.max_value:
raiseValueError(
f'{self.public_name} must be <= {self.max_value}, got {value}'
)
instance.__dict__[self.storage_key] = value
def__delete__(self, instance: Any):
# Removing the key from instance dict effectively 'unsets' the attribute
instance.__dict__.pop(self.storage_key, None)
print(f' [TypeValidated] {self.public_name} deleted from instance')
classWeatherReading:
"""
A single descriptor class powers three completely different validated
attributes. No code duplication, no @property boilerplate per attribute.
"""
temperature_celsius = TypeValidated(float, min_value=-89.2, max_value=56.7)
humidity_percent = TypeValidated(float, min_value=0.0, max_value=100.0)
station_id = TypeValidated(str)
def__init__(self, station_id: str, temperature: float, humidity: float):
self.station_id = station_id
self.temperature_celsius = temperature
self.humidity_percent = humidity
def__repr__(self):
return (
f'WeatherReading(station={self.station_id!r}, '
f'temp={self.temperature_celsius}°C, '
f'humidity={self.humidity_percent}%)'
)
# --- Happy path ---
reading = WeatherReading(station_id='LOND-001', temperature=21.5, humidity=65.0)
print('Created:', reading)
# --- Mutation works and is validated ---
reading.temperature_celsius = -10.0print('Updated temp:', reading)
# --- Wrong type ---try:
reading.temperature_celsius = '22 degrees' # string, not floatexceptTypeErroras e:
print('TypeError caught:', e)
# --- Out of range ---try:
reading.humidity_percent = 150.0exceptValueErroras e:
print('ValueError caught:', e)
# --- Delete the attribute ---del reading.temperature_celsius
try:
print(reading.temperature_celsius)
exceptAttributeErroras e:
print('AttributeError caught:', e)
# --- Check instance __dict__ — notice the _tv_ prefixed keys ---print('\nInstance __dict__:', reading.__dict__)
Pro Tip: Use __set_name__ instead of passing the name to __init__
Before Python 3.6, you'd write temperature = TypeValidated('temperature', float) — repeating the name twice. With __set_name__, Python passes the name automatically. If you're maintaining pre-3.6 code, you must call descriptor.__set_name__(OwnerClass, 'attr_name') manually or the storage_key will be None and every __set__ will silently corrupt all instances.
Production Insight
Omitting __set_name__ in a descriptor and relying on __init__ argument duplication is a common source of bugs when refactoring attribute names. If you forget to update the string argument, validation silently applies to the wrong attribute.
Rule: always use __set_name__ if you support Python 3.6+. It eliminates the duplication and the bug that follows.
Key Takeaway
__set_name__ removes boilerplate and prevents name-sync bugs.
Store per-instance data in instance.__dict__, not on self.
A single descriptor class can power many attributes.
How property, classmethod and staticmethod Are Just Descriptors in Disguise
One of the most illuminating exercises in Python is reimplementing the built-in property from scratch as a pure-Python descriptor. It instantly demystifies how getter/setter chaining works, and it proves that there's no magic — just the protocol you now understand.
classmethod is a non-data descriptor: __get__ returns a bound method with the class as the first argument instead of the instance. staticmethod is also a non-data descriptor: __get__ simply returns the raw underlying function, stripping both self and cls from the equation. Both are elegant proof that Python's method binding system is itself built on top of descriptors.
Understanding this has real production value. If you're writing a library and need a decorator that behaves differently depending on whether it's called on an instance or a class, you implement __get__ and return the appropriate callable. That's exactly what libraries like functools.cached_property do — and knowing the internals means you can write your own variants when the standard library doesn't quite fit.
pure_python_property.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# Re-implement Python's built-in property as a pure descriptor.# This is essentially what CPython's property does in C — same logic.class managed_property:
"""
A pure-Python reimplementation of the built-in property descriptor.
Supports getter, setter, and deleter chaining just like @property.
"""
def__init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
# Mimic property's behaviour: use getter's docstring if none givenself.__doc__ = doc or (fget.__doc__ if fget elseNone)
def__get__(self, instance, owner_class):
if instance isNone:
return self # class-level accessifself.fget isNone:
raiseAttributeError('unreadable attribute')
return self.fget(instance) # call the getter with the instancedef__set__(self, instance, value):
ifself.fset isNone:
raiseAttributeError("can't set attribute — no setter defined")
self.fset(instance, value) # call the setterdef__delete__(self, instance):
ifself.fdel isNone:
raiseAttributeError("can't delete attribute — no deleter defined")
self.fdel(instance)
# These mirror @property.setter / @property.deleter chainingdefsetter(self, fset):
# Return a NEW managed_property with fset filled in; fget is preservedreturntype(self)(self.fget, fset, self.fdel, self.__doc__)
defdeleter(self, fdel):
returntype(self)(self.fget, self.fset, fdel, self.__doc__)
# ----- Reimplemented classmethod for completeness -----class managed_classmethod:
"""Non-data descriptor that binds the class, not the instance."""def__init__(self, func):
self.func = func
def__get__(self, instance, owner_class):
if owner_class isNone:
owner_class = type(instance)
# Return a callable with owner_class pre-bound as first argumentdefbound_class_method(*args, **kwargs):
returnself.func(owner_class, *args, **kwargs)
return bound_class_method
# ----- Demo: use managed_property like @property -----classBankAccount:
def__init__(self, owner: str, initial_balance: float):
self.owner = owner
self._balance = initial_balance # raw storage
@managed_property
defbalance(self) -> float:
"""Current account balance in GBP."""print(' [getter called]')
returnself._balance
@balance.setter
defbalance(self, amount: float):
print(f' [setter called with {amount}]')
if amount < 0:
raiseValueError('Balance cannot go negative')
self._balance = amount
@balance.deleter
defbalance(self):
print(' [deleter called — closing account]')
self._balance = 0.0
@managed_classmethod
defopen_zero_balance(cls, owner: str) -> 'BankAccount':
returncls(owner, 0.0)
account = BankAccount('Alice', 1000.0)
print('Balance:', account.balance)
account.balance = 1500.0print('New balance:', account.balance)
try:
account.balance = -50exceptValueErroras e:
print('Caught:', e)
del account.balance
print('After deletion:', account.balance)
# classmethod via descriptor
empty_account = BankAccount.open_zero_balance('Bob')
print('\nBob account balance:', empty_account.balance)
Output
[getter called]
Balance: 1000.0
[setter called with 1500.0]
[getter called]
New balance: 1500.0
Caught: Balance cannot go negative
[deleter called — closing account]
[getter called]
After deletion: 0.0
[getter called]
Bob account balance: 0.0
Interview Gold: property IS a descriptor
property isn't special syntax — it's just a built-in class that implements __get__, __set__, and __delete__. You can prove it: type(MyClass.some_property) returns <class 'property'>, and dir(property) reveals all three dunder methods. Knowing this cold in an interview signals you understand Python's object model at the implementation level.
Production Insight
When you write a decorator that needs to work both on instances and the class, implement it as a descriptor. For example, a timing decorator could use __get__ to create per-instance timers.
Rule: any decorator that changes behaviour based on caller (instance vs class) should be implemented as a descriptor with __get__.
Key Takeaway
property, classmethod, staticmethod are all descriptors.
Writing a custom decorator that distinguishes instance vs class? Implement __get__.
Understanding this separates library authors from library users.
Performance, Caching and Production Gotchas You Won't Find in the Docs
Descriptors invoke a Python-level function call on every attribute access. For attributes hit thousands of times per second in a tight loop — think coordinate getters in a physics simulation or column accessors in a data pipeline — that overhead is real and measurable. CPython's property is implemented in C, so it's faster than a pure-Python descriptor, but it's still slower than a direct __dict__ lookup.
functools.cached_property is the standard library's answer to this: it's a non-data descriptor that on first access calls the getter, then writes the result directly into instance.__dict__ under the same name. On subsequent accesses, the instance dict wins (non-data descriptor priority) and the function is never called again. No lock, no overhead. The catch: it's not thread-safe by default, and it doesn't work with __slots__ because slots eliminate the instance __dict__.
Another production pattern is the lazy descriptor: heavy initialisation (database connections, file handles, parsed configs) deferred until first access. Combine __set_name__ with cached_property-style logic and you get lazy-loaded class-level resources with zero boilerplate at the call site. The section below shows both a performance benchmark and a thread-safe lazy descriptor you can actually ship.
performance_and_caching_descriptors.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import time
import threading
from functools import cached_property
# ============================================================# 1. PERFORMANCE BENCHMARK — raw dict vs property vs descriptor# ============================================================classDirectDict:
def__init__(self):
self.radius = 5.0# plain attribute — stored in __dict__classWithProperty:
def__init__(self):
self._radius = 5.0
@property
defradius(self):
returnself._radius
classRadiusDescriptor:
def__get__(self, instance, owner):
if instance isNone:
returnselfreturn instance.__dict__.get('_radius', 0.0)
def__set__(self, instance, value):
instance.__dict__['_radius'] = value
classWithDescriptor:
radius = RadiusDescriptor()
def__init__(self):
self.radius = 5.0READS = 2_000_000
for label, obj in [('Direct dict', DirectDict()), ('@property', WithProperty()), ('Custom descriptor', WithDescriptor())]:
start = time.perf_counter()
for _ inrange(READS):
_ = obj.radius
elapsed = time.perf_counter() - start
print(f'{label:<22} {READS:,} reads in {elapsed:.3f}s ({elapsed/READS*1e9:.1f} ns/read)')
# ============================================================# 2. THREAD-SAFE LAZY DESCRIPTOR# Use case: expensive resource initialised once per instance# ============================================================classThreadSafeLazy:
"""
A thread-safe lazy descriptor using a per-instance lock.
Suitablefor expensive __init__ work (DB connections, parsing, etc.)
cached_property isNOT thread-safe; this one is.
"""
def__set_name__(self, owner, name):
self.public_name = name
self.cache_key = f'_lazy_{name}'self.lock_key = f'_lazy_lock_{name}'def__init__(self, factory):
self.factory = factory # callable that produces the expensive valuedef__get__(self, instance, owner):
if instance isNone:
returnself# Ensure a per-instance, per-attribute lock existsifself.lock_key notin instance.__dict__:
instance.__dict__[self.lock_key] = threading.Lock()
lock = instance.__dict__[self.lock_key]
# Double-checked locking patternifself.cache_key notin instance.__dict__:
with lock:
if self.cache_key not in instance.__dict__: # second check inside lockprint(f' [ThreadSafeLazy] computing {self.public_name}...')
instance.__dict__[self.cache_key] = self.factory(instance)
return instance.__dict__[self.cache_key]
# No __set__ — this is intentionally a non-data descriptor so instance dict wins# after first computation. If you need to invalidate, add __set__/__delete__.classReportGenerator:
def__init__(self, data_source: str):
self.data_source = data_source
@ThreadSafeLazydefprocessed_data(self):
"""Simulate an expensive data processing step."""
time.sleep(0.1) # pretend this is a slow DB queryreturn [f'row_{i}'for i inrange(1000)]
@ThreadSafeLazydefsummary_stats(self):
"""Computed from processed_data — also lazy."""return {'count': len(self.processed_data), 'source': self.data_source}
report = ReportGenerator('warehouse_db')
print('\nFirst access (computes):')
print('Row count:', len(report.processed_data))
print('\nSecond access (cached — no recompute):')
print('Row count:', len(report.processed_data))
print('\nSummary:', report.summary_stats)
# Thread safety test — 10 threads race to initialise the same attribute
results = []
defread_data():
results.append(id(report.processed_data)) # should all be the same object
threads = [threading.Thread(target=read_data) for _ inrange(10)]
for t in threads: t.start()
for t in threads: t.join()
print('\nAll threads got same object?', len(set(results)) == 1)
Output
Direct dict 2,000,000 reads in 0.071s (35.5 ns/read)
@property 2,000,000 reads in 0.142s (71.0 ns/read)
Custom descriptor 2,000,000 reads in 0.198s (99.0 ns/read)
functools.cached_property works by writing into instance.__dict__. If your class defines __slots__, there is no __dict__, so the first access raises TypeError: Cannot use cached_property instance without calling __set_name__ or AttributeError depending on Python version. Use the ThreadSafeLazy pattern above (with an explicit slot for the cache key) or drop __slots__ for classes that need cached_property.
Production Insight
The performance difference between a direct dict lookup and a descriptor is roughly 2-3x for reads. In a hot loop called a million times, that's 0.1s difference — acceptable for most apps. But in data pipelines processing billions of rows, it can add minutes.
cached_property is not thread-safe. In a multithreaded web server (e.g., gunicorn with threads), two requests can race to initialise, causing double initialisation or corrupted state. Always use a thread‑safe lazy descriptor for shared resources.
Key Takeaway
Descriptors add overhead — measure before using in hot paths.
cached_property is not thread-safe — build your own with locking.
For __slots__ classes, you must use a custom caching strategy.
When Descriptors Fail: Real-World Patterns and How to Fix Them
Beyond the basic gotchas, descriptors introduce subtle failure modes that only surface under load or in complex inheritance hierarchies. Here are three patterns seen in production codebases.
1. Descriptor in an abstract base class with multiple inheritance — If two parent classes both define the same descriptor attribute, the MRO decides which one wins. If the descriptors have different implementations, the child class may behave unexpectedly. The fix: explicitly define the descriptor on the child class to resolve ambiguity.
2. Using a descriptor to replace @property for many attributes — While it's tempting to replace dozens of properties with a single descriptor factory, debugging becomes harder. The traceback shows the descriptor class, not the attribute name. The fix: override __repr__ on the descriptor to include the attribute name (stored from __set_name__).
3. Circular references in descriptor __get__ — If your descriptor's __get__ accesses another attribute on the same instance that itself triggers a descriptor get, you can create an infinite recursion. This happens when lazy-loaded descriptors reference each other. The fix: use sentinel values and check for recursion depth with a thread-local counter.
Caught recursion: Circular dependency detected for first
repr: <GoodDescriptor for price>
Mental Model: Descriptors Are Like Middleware for Attributes
Data descriptors are always executed — like middleware that runs before the request handler.
Non-data descriptors are fallback middleware — they only run if no value is stored in the instance dict.
Multiple inheritance creates a middleware chain (MRO) — the first descriptor found wins.
Recursive dependencies between descriptors are like circular middleware calling each other — use depth limits or sentinels.
Production Insight
In a large codebase where multiple teams define descriptor-based mixins, MRO collisions are the #1 hidden bug. The symptom: an attribute returns the wrong value with no error. The fix: add a debug check at import time that verifies descriptor ownership using __set_name__ context and warns if the same attribute name appears in multiple parents.
Rule: if you're building a framework with descriptors, enforce that each descriptor stores its owner class name to aid debuggability.
Key Takeaway
MRO can silently swap descriptors — check the resolution order.
Add __repr__ to descriptors to make debugging possible.
Guard against circular lazy loading with recursion depth tracking.
Why Your Django Models Already Use Descriptors (And Why They Burn You)
Every field in a Django model is a descriptor. When you write my_model.name = "foo", you trigger __set__ on a CharField. That triggers validation, coercion, dirty-tracking, and database sync. The problem? Developers treat model fields like plain attributes. They assign directly in loops, bypass caching, and trigger N+1 queries. I've seen a production system drop to 2 req/s because a descriptor on a ForeignKey field triggered a separate database hit for each of 10,000 objects. The fix: batch your updates using bulk_update() or select_related(). Or, if you must touch the raw attribute, use Model.__dict__[field_name] to bypass the descriptor entirely. But know that skips validation. The WHY: Descriptors are not free. They run Python code on every access. Profile before you optimize, but never assume they're cheap.
debug_descriptor_cost.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge
import time
from django.db import models
classUser(models.Model):
name = models.CharField(max_length=100)
# Simulating 10k accesses — each triggers descriptor __get__
users = [User(name=f"user_{i}") for i inrange(10000)]
start = time.perf_counter()
for u in users:
_ = u.name # Descriptor __get__: checks cache, returns str
end = time.perf_counter()
print(f"10k descriptor accesses: {end - start:.4f}s")
Output
10k descriptor accesses: 0.0234s
Production Trap:
Django model fields are descriptors. They enforce type checks, run validators, and sync state. Assigning in a tight loop multiplies that cost. Always check if the descriptor's __set__ is idempotent before writing bulk operations.
Key Takeaway
Descriptor overhead scales linearly with access count. Batch or bypass when throughput matters.
The "Read-Only" Descriptor Lie: How to Make a Real One (That Won't Leak)
Junior devs love read-only descriptors. They write __set__ that raises AttributeError. That's fine for toy code. In production, it breaks pickle, copy, and __init__ that needs to bypass the guard. The WHY: Python's copy protocol calls __set__ on the new object. If your descriptor rejects it, you get silent corruption or a PicklingError at 3 AM. The fix: make your descriptor writable, but only from __init__ or __set_name__. Store the real value in a mangled name like self._MyDescriptor__value. Use a flag like self._locked that you set after __init__ completes. Alternatively, use __set_name__ to register the attribute name, then check sys._getframe(1).f_code.co_name == '__init__' — ugly but works. The cleanest pattern: accept writes always, but ignore them after first assignment. This matches property behavior and doesn't break serialization.
safe_readonly.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// io.thecodeforge
import copy
classLockedAttr:
def__set_name__(self, owner, name):
self.storage = f"_{name}_locked"def__get__(self, obj, objtype=None):
if obj isNone:
returnselfreturngetattr(obj, self.storage, None)
def__set__(self, obj, value):
ifhasattr(obj, self.storage):
raiseAttributeError(f"{self.storage[1:]} is read-only")
object.__setattr__(obj, self.storage, value)
classConfig:
api_key = LockedAttr()
def__init__(self, key):
self.api_key = key # Works once
cfg = Config("sk-abc")
try:
cfg.api_key = "hacked"exceptAttributeErroras e:
print(e) # "api_key is read-only"# Copy still works
cfg2 = copy.deepcopy(cfg)
print(cfg2.api_key) # 'sk-abc'
Output
api_key is read-only
sk-abc
Production Trap:
Raising AttributeError in __set__ breaks copy and pickle. Allow initial write, then lock. Use __set_name__ to generate unique storage names per class.
Key Takeaway
Real read-only descriptors permit one write. Never block __set__ entirely in production code.
● Production incidentPOST-MORTEMseverity: high
Shared State Between Instances Due to Missing Instance Dict Storage
Symptom
Two different database servers in the same process reported identical connection counts and error rates, even though their actual workloads differed by 300%. The graphs showed perfect overlap — impossible in production.
Assumption
The team assumed the descriptor stored values per instance because it used self.value = x in __set__. They thought the descriptor object was created per instance, not per class.
Root cause
The descriptor was a class-level object shared by all instances. self.value inside __set__ stored the data on the descriptor instance itself, not on the owning object's __dict__. Every write overwrote the same slot, so the last server to write its metrics clobbered everyone else's data.
Fix
Changed storage to use instance.__dict__[self.storage_key] = value with a mangled key set in __set_name__. Added a unit test that created two instances, set different values, and asserted they remained independent.
Key lesson
Never store per-instance data on the descriptor object itself — it's a singleton for all instances.
Always use a unique key in instance.__dict__, typically prefixed with an underscore and descriptor name.
Add a test that verifies two instances of the same class can hold different values through the descriptor.
Production debug guideSymptom → Action for the five most common descriptor failures in production5 entries
Symptom · 01
AttributeError: 'NoneType' object has no attribute '__dict__' when accessing descriptor on the class
→
Fix
Add if instance is None: return self at the top of __get__. The class-level access passes None as the instance — you must handle it.
Symptom · 02
Setting an attribute on one instance changes the value for all other instances
→
Fix
Find self.some_attr = value inside __set__ or __init__ of the descriptor. Change to instance.__dict__[self.storage_key] = value. The descriptor object is shared; instance data must live in the instance's own dict.
Symptom · 03
cached_property raises TypeError: Cannot use cached_property instance without calling __set_name__
→
Fix
Check if the class uses __slots__ without __dict__. cached_property writes to instance.__dict__. Either add '__dict__' to __slots__, drop __slots__, or use a custom caching descriptor that stores via a slot.
Symptom · 04
Instance method being shadowed by an instance attribute unexpectedly
→
Fix
Understand that functions are non-data descriptors. Instance dict (e.g. obj.method = lambda ...) shadows the class method. If you need to prevent shadowing, make the descriptor a data descriptor by adding a trivial __set__ that raises AttributeError.
Symptom · 05
Descriptor's __get__ never called on first access (value comes from __dict__ directly)
→
Fix
Check if another part of the code writes directly to instance.__dict__['attr'] instead of instance.attr = .... That bypasses the descriptor entirely. Audit all assignments to that attribute.
★ Descriptor Cheat SheetQuick commands and checks for diagnosing descriptor behaviour at runtime
Unsure if an attribute is handled by a descriptor?−
Immediate action
Check the class's __dict__ and MRO for descriptor objects
Commands
type(instance).__dict__.get('attr') or next((c.__dict__.get('attr') for c in type(instance).__mro__ if 'attr' in c.__dict__), None)
type(instance).attr # class-level access - if it returns the descriptor, that's your object
Fix now
If it returns a descriptor class, add a debug print: print(f'Descriptor __get__ called, instance={instance}')
Is it a data or non-data descriptor?+
Immediate action
Check for __set__ or __delete__
Commands
hasattr(type(instance).__dict__.get('attr'), '__set__') or hasattr(type(instance).__dict__.get('attr'), '__delete__')
hasattr(type(instance).attr, '__set__') # class access returns the descriptor
Fix now
If you need it to be a data descriptor (always win), add __set__ method
Per-instance data is shared?+
Immediate action
Print the descriptor's __dict__ to see if it contains instance data
Commands
print(type(instance).attr.__dict__) # class attribute -> descriptor object
print(instance.__dict__) # check for mangled keys
Fix now
Move storage from descriptor.__dict__ to instance.__dict__[self.storage_key]
cached_property fails with TypeError+
Immediate action
Check if class has __slots__ and if __dict__ is missing
Commands
hasattr(instance, '__dict__')
'__dict__' in type(instance).__slots__ if hasattr(type(instance), '__slots__') else 'no slots'
Fix now
Add '__dict__' to __slots__ tuple or remove __slots__
High — direct instance dict write silently shadows
Performance overhead
Every read + write goes through Python call
Every read goes through Python call; writes bypass
Thread-safe caching possible?
Yes — store in instance dict inside __set__
Yes, but requires careful double-checked locking
Key takeaways
1
Descriptors control attribute access via __get__, __set__, __delete__.
2
Data descriptors win over instance dict; non-data descriptors lose.
3
Use __set_name__ to avoid boilerplate and prevent name-sync bugs.
4
Store per-instance data in instance.__dict__ with a mangled key.
5
property, classmethod, staticmethod are all built-in descriptors.
6
Add __repr__ to descriptors
saves hours of debugging.
7
Thread-safe caching requires double-checked locking or per-instance locks.
8
Multiple inheritance can silently swap descriptors
check MRO.
Common mistakes to avoid
5 patterns
×
Storing per-instance data on the descriptor itself
Symptom
Setting obj_a.attr = 1 also changes obj_b.attr to 1 — all instances share the same value.
Fix
Always store data in instance.__dict__ using a unique key (e.g., self.storage_key set in __set_name__). Never do self.value = x inside __set__.
×
Forgetting the `if instance is None` guard in `__get__`
Symptom
Accessing MyClass.descriptor_attr raises AttributeError: 'NoneType' object has no attribute '__dict__'.
Fix
Add if instance is None: return self as the first line in __get__. This handles class-level access.
×
Using `cached_property` on a class with `__slots__`
Symptom
TypeError: Cannot use cached_property instance without calling __set_name__ or silent AttributeError.
Fix
Either add '__dict__' to __slots__, drop __slots__, or use a custom caching descriptor that stores data in a slot or via the descriptor's own dict with locking.
×
Creating a descriptor that doesn't handle inheritance correctly
Symptom
A child class unexpectedly inherits the descriptor's state from the parent class, or the descriptor behaves differently depending on which parent is listed first in MRO.
Fix
Ensure the descriptor's __set_name__ records the owner class so it can differentiate. For MRO-sensitive cases, explicitly redeclare the descriptor on the child class.
×
Omitting `__repr__` on a custom descriptor
Symptom
During debugging, print(MyClass.attr) shows <__main__.MyDescriptor object at 0x...> — no indication of which attribute it belongs to.
Fix
Implement __repr__ to include the attribute name (captured by __set_name__) and the owner class: return f'<{type(self).__name__} for {self.owner.__name__}.{self.name}>'.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is a descriptor in Python, and what are the three methods that defi...
Q02SENIOR
Explain the difference between a data descriptor and a non-data descript...
Q03SENIOR
How does `__set_name__` improve descriptor design, and what was the work...
Q04SENIOR
Describe a production scenario where a descriptor storing state on itsel...
Q05SENIOR
How would you implement a caching decorator for a method that is safe to...
Q01 of 05JUNIOR
What is a descriptor in Python, and what are the three methods that define the descriptor protocol?
ANSWER
A descriptor is an object that defines at least one of __get__, __set__, or __delete__. These methods intercept attribute access on instances of the class where the descriptor is assigned as a class attribute. __get__(self, instance, owner) handles reading, __set__(self, instance, value) handles writing, and __delete__(self, instance) handles deletion. The descriptor protocol is the mechanism behind property, classmethod, staticmethod, __slots__, and many ORM field implementations.
Q02 of 05SENIOR
Explain the difference between a data descriptor and a non-data descriptor. Give an example of when you would choose one over the other.
ANSWER
A data descriptor defines both __get__ and at least one of __set__ or __delete__. A non-data descriptor only defines __get__. The critical difference is lookup priority: data descriptors always win over instance __dict__, while non-data descriptors lose to instance __dict__.
Choose a data descriptor when you need to control attribute assignment and prevent shadowing — for example, a validated field that must always run type checking. Choose a non-data descriptor when you want to allow per-instance overrides — for example, a method that can be replaced on a specific instance (like mocking a method in tests). @property is a data descriptor because it always intercepts reads and writes. A plain function (non-data descriptor) can be shadowed by instance.method = lambda x: x.
Q03 of 05SENIOR
How does `__set_name__` improve descriptor design, and what was the workaround before Python 3.6?
ANSWER
__set_name__(self, owner, name) is called automatically when the class body is executed, providing the descriptor with the owning class and the attribute name it was assigned to. Before Python 3.6, you had to pass the name explicitly as a constructor argument: class Foo: bar = MyDescriptor('bar'). This was error-prone because renaming the attribute required updating the string argument. __set_name__ eliminates that duplication and reduces bugs. If you're maintaining pre-3.6 code, you must manually call descriptor.__set_name__(Owner, 'name') after class creation, or the storage key will be None and all writes will silently overwrite each other.
Q04 of 05SENIOR
Describe a production scenario where a descriptor storing state on itself caused data corruption, and how you would prevent it.
ANSWER
Consider a descriptor that tracks how many times an attribute is accessed: class Counter: def __get__(self, instance, owner): self.count += 1; return .... If the descriptor is a class attribute, self.count is shared across all instances. When a web server processes requests for different users, the counter will be incremented globally, not per-user. The fix: store the counter in instance.__dict__ using a mangled key like '_counter_' + self.attr_name. This ensures each instance maintains its own count. The prevention rule: never store per-instance mutable state on the descriptor object itself; always use instance.__dict__ with a unique key.
Q05 of 05SENIOR
How would you implement a caching decorator for a method that is safe to use in a multi-threaded environment, using descriptors?
ANSWER
I'd create a descriptor-based decorator similar to functools.cached_property but with thread safety. The descriptor would use a per-instance lock stored in instance.__dict__ (initialized lazily). On first access via __get__, a double-checked locking pattern is used: check if the cache key exists, if not acquire the lock, check again, then compute and store. By making it a non-data descriptor (no __set__), subsequent accesses use the instance dict directly — no lock overhead. The storage key would be mangled with the attribute name to avoid collisions. This pattern is used internally by many web frameworks for lazy-loaded connection pools or configuration objects.
01
What is a descriptor in Python, and what are the three methods that define the descriptor protocol?
JUNIOR
02
Explain the difference between a data descriptor and a non-data descriptor. Give an example of when you would choose one over the other.
SENIOR
03
How does `__set_name__` improve descriptor design, and what was the workaround before Python 3.6?
SENIOR
04
Describe a production scenario where a descriptor storing state on itself caused data corruption, and how you would prevent it.
SENIOR
05
How would you implement a caching decorator for a method that is safe to use in a multi-threaded environment, using descriptors?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Can I use a descriptor on a class level (not on instances)?
Yes. When you access a descriptor on the class itself (e.g., MyClass.attr), Python calls __get__ with instance=None and owner=MyClass. You must handle this in your __get__ implementation — typically by returning self (the descriptor object) or a class-level value. If you don't, you'll get an AttributeError when trying to access instance.__dict__ on None.
Was this helpful?
02
What's the difference between a descriptor and a property?
A property is a specific type of descriptor — it's a built-in class that implements the full descriptor protocol (__get__, __set__, __delete__). You can write your own descriptors to do anything a property does and more: validation, type checking, lazy loading, logging, and cross-attribute coordination. Properties are syntactic sugar for simple getter/setter patterns; descriptors are a general-purpose hook into the attribute system.
Was this helpful?
03
Why does cached_property not work with __slots__?
cached_property works by writing the cached value directly into instance.__dict__ under the attribute name. If a class defines __slots__, instances don't have a __dict__ unless explicitly included (__slots__ = ('__dict__',)). Without __dict__, cached_property has nowhere to store the computed value and raises a TypeError. Workaround: either add '__dict__' to __slots__, drop __slots__, or implement a custom caching descriptor that stores the value in a designated slot.
Was this helpful?
04
Can a descriptor be used on a function or method?
No — descriptors are defined on classes, not on functions. The __get__ method of a function is what makes it a descriptor (it returns a bound method when accessed on an instance). But you cannot assign a descriptor object as an attribute of a function; it must be a class attribute. If you want to intercept attribute access on a module-level object, consider using __getattr__ on the module or a proxy class.
Was this helpful?
05
How do I debug why a descriptor isn't being called?
First, check if the attribute is defined on the class or on the instance. Print type(instance).__dict__.get('attr') — if it returns a descriptor object, it's a class attribute. Then check if it's a data or non-data descriptor (presence of __set__ or __delete__). If it's a non-data descriptor and the instance has the same key in __dict__, the instance value wins. Temporarily add a print() inside __get__ to confirm it's being invoked. Also verify that __set_name__ was called (the storage key should not be None).