Home Python Python Descriptors Explained — How __get__, __set__ and __delete__ Really Work

Python Descriptors Explained — How __get__, __set__ and __delete__ Really Work

In Plain English 🔥
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.
⚡ Quick Answer
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.

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.py · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
# 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
▶ 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-negotiableIf 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.

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.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
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__)
▶ Output
Created: WeatherReading(station='LOND-001', temp=21.5°C, humidity=65.0%)
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}
⚠️
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.

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.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
# 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)
▶ 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 ``, 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.

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.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
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)
▶ 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)

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
⚠️
Watch Out: cached_property breaks with __slots__`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`.
AspectData DescriptorNon-Data Descriptor
Methods required__get__ + __set__ and/or __delete____get__ only
Lookup priority vs instance dictWins — overrides instance dictLoses — instance dict takes precedence
Typical use casesproperty, validated attributes, ORM fieldsMethods, classmethod, staticmethod, cached_property
Can be shadowed per-instance?No — descriptor always interceptsYes — assigning to instance.__dict__ shadows it
Accidental override riskLow — instance dict writes go through __set__High — direct instance dict write silently shadows
Performance overheadEvery read + write goes through Python callEvery 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 = x inside __set__ means every instance of the owner class shares one variable, so setting obj_a.attr = 1 overwrites obj_b.attr. Fix: always store data in instance.__dict__ using a unique key like instance.__dict__[self.storage_key] = x.
    • Mistake 2: Forgetting the if instance is None guard in __get__ — accessing the descriptor at the class level (e.g. MyClass.attr) passes None as the instance. Without the guard, instance.__dict__ raises AttributeError: 'NoneType' object has no attribute '__dict__'. Fix: add if instance is None: return self as the very first line of __get__.
    • Mistake 3: Using cached_property on a class with __slots__cached_property is a non-data descriptor that caches by writing to instance.__dict__, which doesn't exist when __slots__ is defined. The symptom is a TypeError or 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.
    🔥
    TheCodeForge Editorial Team Verified Author

    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.

    ← PreviousMemory Management in PythonNext →Type Hints in Python
    Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged