Python Descriptors Explained — How __get__, __set__ and __delete__ Really Work
property, classmethod, staticmethod, and __slots__ — you've been using them all along without knowing it.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.
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.
# Demonstrate the data vs non-data descriptor priority difference class DataDescriptor: """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 manually self.storage_key = f'_dd_{attribute_name}' def __get__(self, instance, owner_class): if instance is None: # Accessed on the class itself, not an instance — return the descriptor return self return 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 class NonDataDescriptor: """Only defines __get__ — instance dict takes priority over this.""" def __get__(self, instance, owner_class): if instance is None: return self return 'value from NonDataDescriptor' class Experiment: 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
[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...>
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.
from typing import Any, Type, Optional class TypeValidated: """ 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 is None: return self # class-level access returns the descriptor # Retrieve from instance.__dict__; raise AttributeError if not yet set try: return instance.__dict__[self.storage_key] except KeyError: raise AttributeError( f'{owner_class.__name__}.{self.public_name} has not been set' ) def __set__(self, instance: Any, value: Any): # --- Type check --- if not isinstance(value, self.expected_type): raise TypeError( f'{self.public_name} expects {self.expected_type.__name__}, ' f'got {type(value).__name__} instead' ) # --- Range check (only for numeric types) --- if self.min_value is not None and value < self.min_value: raise ValueError( f'{self.public_name} must be >= {self.min_value}, got {value}' ) if self.max_value is not None and value > self.max_value: raise ValueError( 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') class WeatherReading: """ 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.0 print('Updated temp:', reading) # --- Wrong type --- try: reading.temperature_celsius = '22 degrees' # string, not float except TypeError as e: print('TypeError caught:', e) # --- Out of range --- try: reading.humidity_percent = 150.0 except ValueError as e: print('ValueError caught:', e) # --- Delete the attribute --- del reading.temperature_celsius try: print(reading.temperature_celsius) except AttributeError as e: print('AttributeError caught:', e) # --- Check instance __dict__ — notice the _tv_ prefixed keys --- print('\nInstance __dict__:', reading.__dict__)
Updated temp: WeatherReading(station='LOND-001', temp=-10.0°C, humidity=65.0%)
TypeError caught: temperature_celsius expects float, got str instead
ValueError caught: humidity_percent must be <= 100.0, got 150.0
[TypeValidated] temperature_celsius deleted from instance
AttributeError caught: WeatherReading.temperature_celsius has not been set
Instance __dict__: {'_tv_station_id': 'LOND-001', '_tv_humidity_percent': 65.0}
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.
# 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 given self.__doc__ = doc or (fget.__doc__ if fget else None) def __get__(self, instance, owner_class): if instance is None: return self # class-level access if self.fget is None: raise AttributeError('unreadable attribute') return self.fget(instance) # call the getter with the instance def __set__(self, instance, value): if self.fset is None: raise AttributeError("can't set attribute — no setter defined") self.fset(instance, value) # call the setter def __delete__(self, instance): if self.fdel is None: raise AttributeError("can't delete attribute — no deleter defined") self.fdel(instance) # These mirror @property.setter / @property.deleter chaining def setter(self, fset): # Return a NEW managed_property with fset filled in; fget is preserved return type(self)(self.fget, fset, self.fdel, self.__doc__) def deleter(self, fdel): return type(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 is None: owner_class = type(instance) # Return a callable with owner_class pre-bound as first argument def bound_class_method(*args, **kwargs): return self.func(owner_class, *args, **kwargs) return bound_class_method # ----- Demo: use managed_property like @property ----- class BankAccount: def __init__(self, owner: str, initial_balance: float): self.owner = owner self._balance = initial_balance # raw storage @managed_property def balance(self) -> float: """Current account balance in GBP.""" print(' [getter called]') return self._balance @balance.setter def balance(self, amount: float): print(f' [setter called with {amount}]') if amount < 0: raise ValueError('Balance cannot go negative') self._balance = amount @balance.deleter def balance(self): print(' [deleter called — closing account]') self._balance = 0.0 @managed_classmethod def open_zero_balance(cls, owner: str) -> 'BankAccount': return cls(owner, 0.0) account = BankAccount('Alice', 1000.0) print('Balance:', account.balance) account.balance = 1500.0 print('New balance:', account.balance) try: account.balance = -50 except ValueError as 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)
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
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.
import time import threading from functools import cached_property # ============================================================ # 1. PERFORMANCE BENCHMARK — raw dict vs property vs descriptor # ============================================================ class DirectDict: def __init__(self): self.radius = 5.0 # plain attribute — stored in __dict__ class WithProperty: def __init__(self): self._radius = 5.0 @property def radius(self): return self._radius class RadiusDescriptor: def __get__(self, instance, owner): if instance is None: return self return instance.__dict__.get('_radius', 0.0) def __set__(self, instance, value): instance.__dict__['_radius'] = value class WithDescriptor: radius = RadiusDescriptor() def __init__(self): self.radius = 5.0 READS = 2_000_000 for label, obj in [('Direct dict', DirectDict()), ('@property', WithProperty()), ('Custom descriptor', WithDescriptor())]: start = time.perf_counter() for _ in range(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 # ============================================================ class ThreadSafeLazy: """ A thread-safe lazy descriptor using a per-instance lock. Suitable for expensive __init__ work (DB connections, parsing, etc.) cached_property is NOT 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 value def __get__(self, instance, owner): if instance is None: return self # Ensure a per-instance, per-attribute lock exists if self.lock_key not in instance.__dict__: instance.__dict__[self.lock_key] = threading.Lock() lock = instance.__dict__[self.lock_key] # Double-checked locking pattern if self.cache_key not in instance.__dict__: with lock: if self.cache_key not in instance.__dict__: # second check inside lock print(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__. class ReportGenerator: def __init__(self, data_source: str): self.data_source = data_source @ThreadSafeLazy def processed_data(self): """Simulate an expensive data processing step.""" time.sleep(0.1) # pretend this is a slow DB query return [f'row_{i}' for i in range(1000)] @ThreadSafeLazy def summary_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 = [] def read_data(): results.append(id(report.processed_data)) # should all be the same object threads = [threading.Thread(target=read_data) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print('\nAll threads got same object?', len(set(results)) == 1)
@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)
First access (computes):
[ThreadSafeLazy] computing processed_data...
Row count: 1000
Second access (cached — no recompute):
Row count: 1000
[ThreadSafeLazy] computing summary_stats...
Summary: {'count': 1000, 'source': 'warehouse_db'}
All threads got same object? True
| Aspect | Data Descriptor | Non-Data Descriptor |
|---|---|---|
| Methods required | __get__ + __set__ and/or __delete__ | __get__ only |
| Lookup priority vs instance dict | Wins — overrides instance dict | Loses — instance dict takes precedence |
| Typical use cases | property, validated attributes, ORM fields | Methods, classmethod, staticmethod, cached_property |
| Can be shadowed per-instance? | No — descriptor always intercepts | Yes — assigning to instance.__dict__ shadows it |
| Accidental override risk | Low — instance dict writes go through __set__ | 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
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Storing per-instance data on the descriptor itself —
self.value = xinside__set__means every instance of the owner class shares one variable, so settingobj_a.attr = 1overwritesobj_b.attr. Fix: always store data ininstance.__dict__using a unique key likeinstance.__dict__[self.storage_key] = x. - ✕Mistake 2: Forgetting the
if instance is Noneguard in__get__— accessing the descriptor at the class level (e.g.MyClass.attr) passesNoneas the instance. Without the guard,instance.__dict__raisesAttributeError: 'NoneType' object has no attribute '__dict__'. Fix: addif instance is None: return selfas the very first line of__get__. - ✕Mistake 3: Using
cached_propertyon a class with__slots__—cached_propertyis a non-data descriptor that caches by writing toinstance.__dict__, which doesn't exist when__slots__is defined. The symptom is aTypeErroror silent failure depending on Python version. Fix: either remove__slots__, add__dict__explicitly to__slots__, or implement your own caching descriptor that stores into a declared slot.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.