Python Non-Data Descriptors: How They Work and When to Use Them
python non data descriptor: Understand Python non-data descriptors, the descriptor protocol, and how they differ from data descriptors with practical examples.
python non data descriptor requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When you access an attribute on a Python object, the interpreter follows a precise lookup order. The descriptor protocol is part of that order, and a Python non-data descriptor is the simpler half of that protocol: it only implements __get__, leaving attribute assignment to the instance dictionary. Understanding how non-data descriptors behave is essential for writing clean metaprogramming code, and it explains why methods work the way they do.
The Descriptor Protocol in Python
A descriptor is any object that implements one or more of the methods __get__, __set__, or __delete__. These methods intercept attribute access on an instance of a class that owns the descriptor as a class attribute.
__get__(self, instance, owner)is called when the attribute is read.__set__(self, instance, value)is called when the attribute is assigned.__delete__(self, instance)is called when the attribute is deleted.
A descriptor that implements __set__ or __delete__ is called a data descriptor. A descriptor that implements only __get__ is called a non-data descriptor. This distinction matters because it changes how the interpreter resolves attribute access.
What Is a Non-Data Descriptor?
A Python non-data descriptor is an object that defines __get__ but not __set__ or __delete__. Because it lacks __set__, it cannot override assignment to the instance's __dict__. This single difference has profound consequences for attribute lookup precedence.
Here is the simplest possible non-data descriptor:
class NonDataDescriptor: def __get__(self, instance, owner): return "value from descriptor"
When this descriptor is placed as a class attribute, reading the attribute triggers __get__, but writing to the attribute creates an entry in the instance dictionary that shadows the descriptor for that instance.
Attribute Lookup Precedence
The Python data model defines a precise order for attribute access. For an instance attribute lookup, the interpreter checks:
- Data descriptors defined on the class (or its bases).
- The instance's
__dict__. - Non-data descriptors defined on the class.
- The class's
__dict__(if no descriptor is found).
This order explains the key behavior of non-data descriptors: they are consulted only after the instance dictionary is checked. If an instance has an attribute with the same name, the instance dictionary wins and the non-data descriptor is never called.
Consider the following example:
class Example: value = NonDataDescriptor() ex = Example() ex.value # Calls NonDataDescriptor.__get__ ex.value = 42 # Creates an entry in ex.__dict__ ex.value # Returns 42, the instance dict entry shadows the descriptor
After the assignment, ex.value no longer invokes the descriptor. This is the defining characteristic of a non-data descriptor.
Method Binding: The Most Common Non-Data Descriptor
Every function defined in a class is a non-data descriptor. The function type implements __get__ to return a bound method when accessed through an instance. This is how Python automatically passes self to instance methods.
class Greeter: def greet(self, name): return f"Hello, {name}" g = Greeter() g.greet # Bound method, self is g g.greet("Alice") # Output: Hello, Alice
Because functions are non-data descriptors, you can assign a function to an instance attribute and it will shadow the method for that instance:
g.greet = lambda name: f"Custom: {name}" g.greet("Bob") # Output: Custom: Bob
This behavior is intentional. It allows per-instance overriding of methods without affecting other instances. If functions were data descriptors, this would not be possible.
Implementing a Non-Data Descriptor
A practical use of a non-data descriptor is lazy evaluation. The descriptor computes a value on first access and caches it in the instance dictionary. Because the descriptor is non-data, the cached value in the instance dict takes precedence on subsequent accesses.
class CachedProperty: def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, instance, owner): if instance is None: return self value = self.func(instance) instance.__dict__[self.name] = value return value class Circle: def __init__(self, radius): self.radius = radius @CachedProperty def area(self): print("Computing area") return 3.14159 * self.radius ** 2 c = Circle(2) c.area # Prints "Computing area" c.area # No print; value is cached in c.__dict__
The first access computes the area and stores it in the instance dictionary. The second access finds the cached value and never calls the descriptor again. This pattern is the basis for functools.cached_property, though the standard library version uses a data descriptor to handle mutable instances more safely.
Data vs Non-Data Descriptor: Choosing the Right One
The choice between a data and non-data descriptor depends on whether you need to control writes. The following table summarizes the key differences:
| Feature | Data Descriptor | Non-Data Descriptor |
|---|---|---|
Implements __set__ | Yes | No |
| Precedence over instance dict | Yes | No |
| Can override assignment | Yes | No |
| Typical use cases | Validation, computed attributes with setter logic | Method binding, lazy caching, read-only computed values |
| Instance dict shadowing | Never | Possible |
Use a data descriptor when you need to intercept writes, enforce invariants, or make a computed attribute read-only. Use a non-data descriptor when you want a lightweight computed value that can be overridden per instance, or when you are implementing a decorator that should not block assignment.
Practical Considerations and Pitfalls
Non-data descriptors are simple, but they have subtle behaviors that can surprise developers.
Performance
Attribute lookup that hits a non-data descriptor is slightly slower than a plain instance dict lookup because the interpreter must first check the instance dict, then the class dict, and then call the descriptor. For most applications this overhead is negligible, but in tight loops you may want to cache the descriptor result locally.
Shadowing Confusion
Because instance assignment shadows the descriptor, a class that uses a non-data descriptor can accidentally lose the descriptor behavior if any code assigns to the same attribute name. This is often the source of bugs when a descriptor is added to an existing class after instances have already been created.
Missing __set__ Can Be Intentional
Sometimes developers assume that any descriptor should block writes. If you need that guarantee, you must implement __set__ (even if it just raises an exception). A non-data descriptor will not prevent assignment, which can be a security or correctness issue if the descriptor is meant to be immutable.
Advanced Usage: __set_name__ and Class-Level Registration
The descriptor protocol also includes __set_name__(self, owner, name), which is called when the descriptor is assigned to a class attribute. This is useful for registering the descriptor's attribute name without hardcoding it.
class Field: def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner): if instance is None: return self return instance.__dict__.get(self.name) class Model: name = Field() age = Field() m = Model() m.name = "Alice" m.name # Returns "Alice"
Here Field is a non-data descriptor because it only implements __get__. The __set_name__ hook lets it know its attribute name, which is then used to look up the value in the instance dictionary. This pattern is common in ORMs and serialization libraries where field names must be known at class definition time.
Combining __set_name__ with a non-data descriptor gives you a clean way to build declarative APIs without the complexity of a data descriptor. The instance dictionary remains the source of truth for stored values, while the descriptor provides computed or transformed access.