Python Descriptors and `__get__`

Learn how Python descriptors work: the __get__, __set__, and __delete__ protocol that powers properties, methods, and attribute access control.

8 min read

A descriptor in Python is an object that controls what happens when you access an attribute on another object. If a class attribute implements the descriptor protocol, Python calls the descriptor's methods instead of returning the attribute directly. This is the mechanism behind the property decorator, class methods, static methods, and many advanced customization patterns.

The article on customizing Python objects with magic methods covered attribute access hooks at the object level. Descriptors operate at the class level and give you reusable, shareable attribute behaviour that works across multiple classes.

Understanding descriptors unlocks the ability to build your own versions of property-like features, create ORM field definitions, and implement validation that works correctly with Python's lookup machinery.

The descriptor protocol

The descriptor protocol consists of three methods. The get method receives the instance and the owner class, the set method receives the instance and the new value, and the delete method receives the instance.

An object becomes a descriptor by implementing at least one of these methods. The most basic descriptor only implements get and returns a computed value.

pythonpython
class Constant:
    def __init__(self, value):
        self.value = value
 
    def __get__(self, instance, owner):
        return self.value
 
class Config:
    max_connections = Constant(100)
    timeout = Constant(30)
 
cfg = Config()
print(cfg.max_connections)
print(cfg.timeout)

The Constant descriptor returns the same value regardless of which instance accesses it. The descriptor object is stored on the class, but Python calls its get method rather than returning the descriptor itself.

texttext
100
30

The get method receives two arguments: instance is the object through which the attribute was accessed, and owner is the class where the descriptor was defined. If the attribute was accessed on the class itself rather than an instance, instance is None.

How data descriptors differ from non-data descriptors

A descriptor that implements both get and set (or get and delete) is called a data descriptor. A descriptor that implements only get is a non-data descriptor. The distinction matters because Python gives data descriptors priority over instance dictionaries during attribute lookup.

When you access obj.attr, Python first looks for a data descriptor named attr on the type, and if one exists, its get method is called. If no data descriptor is found, Python checks the instance dictionary. If the key is not there, Python looks for a non-data descriptor on the type.

This priority rule is why the property decorator works correctly. A property is a data descriptor. When you set obj.prop = value, the property's set method fires even if the instance dictionary already has a key named prop.

Here is a data descriptor that validates values before storing them. The descriptor keeps per-instance data in a dictionary keyed by the instance id.

pythonpython
class ValidatedAttribute:
    def __init__(self, name, validator):
        self.name = name
        self.validator = validator
        self.storage = {}
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.storage.get(id(instance))
 
    def __set__(self, instance, value):
        if not self.validator(value):
            raise ValueError(f"{self.name}: invalid value {value}")
        self.storage[id(instance)] = value

With the descriptor class defined, applying it to a class attribute is a single line. The descriptor handles all storage and validation behind the scenes.

pythonpython
def positive(value):
    return value > 0
 
class Product:
    price = ValidatedAttribute("price", positive)
 
p = Product()
p.price = 99
print(p.price)

The price attribute is a data descriptor. Every access and assignment goes through the descriptor's get and set methods. The validation runs on every write.

texttext
99

Notice the data storage uses a dictionary keyed by instance id. This is a common pattern because the descriptor object is shared across all instances of the class, so it cannot store per-instance data on itself.

Keying by id() has a real downside: entries are never removed, and once an instance is garbage collected, Python can reuse its id for a new object, which would then read stale data left behind by the old one. Production code should key a WeakKeyDictionary by the instance itself instead, so entries disappear automatically when the instance does.

Where descriptors appear in standard Python

You already use descriptors every day without realizing it. The property decorator creates a descriptor object behind the scenes. When you write an at-property method, Python builds a descriptor with get, set, and delete methods from your function definitions.

Functions themselves are descriptors. When you access a method on an instance, Python calls the function's get method, which returns a bound method object. This is why self is automatically passed as the first argument.

pythonpython
class Demo:
    def method(self):
        return "called"
 
d = Demo()
bound = d.method
print(bound())
print(Demo.method.__get__(d, Demo)())

Both calls produce the same result. Accessing d.method calls the function descriptor's get method, which binds the instance to the function and returns a callable that fills in self automatically.

texttext
called
called

Class methods and static methods are also descriptors. The classmethod descriptor's get method passes the class instead of the instance as the first argument. The staticmethod descriptor's get method simply returns the original function unchanged.

Building a reusable typed attribute descriptor

The real power of descriptors is reusability. You write the validation logic once as a descriptor class and apply it to any attribute on any class. Here is a descriptor that enforces type constraints.

pythonpython
class Typed:
    def __init__(self, name, expected_type):
        self.name = name
        self.expected_type = expected_type
        self.storage = {}
 
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.storage.get(id(instance))
 
    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self.name} must be {self.expected_type.__name__}"
            )
        self.storage[id(instance)] = value

Now you can declare typed fields on any class with minimal boilerplate. Each field knows its own name and expected type.

pythonpython
class Account:
    holder = Typed("holder", str)
    balance = Typed("balance", float)
 
a = Account()
a.holder = "Maya"
a.balance = 100.0
print(a.holder, a.balance)

The descriptor validates every assignment. Assigning a string to balance would raise TypeError immediately, before the value is stored anywhere.

texttext
Maya 100.0

This pattern is the foundation of how ORMs define database columns, how form libraries validate input, and how configuration systems enforce constraints. The article on Python properties vs descriptors compares this approach to the property decorator for common use cases.

Rune AI

Rune AI

Key Insights

  • A descriptor implements get, and optionally set or delete, to control attribute access on another object.
  • Data descriptors implement get and set/delete; they take priority over instance dictionaries.
  • Non-data descriptors implement only get; instance attributes can override them.
  • The property decorator, classmethod, and staticmethod are all descriptors provided by the standard library.
  • Descriptors receive the instance and the owner class, giving them full context for each access.
RunePowered by Rune AI

Frequently Asked Questions

What is a descriptor in Python?

A descriptor is an object that implements at least one of __get__, __set__, or __delete__ from the descriptor protocol. Descriptors control how attribute access works on another object. When a descriptor is stored as a class attribute, Python invokes its protocol methods instead of returning the descriptor object itself, giving you fine-grained control over get, set, and delete operations.

What is the difference between data and non-data descriptors?

A data descriptor implements both __get__ and either __set__ or __delete__. A non-data descriptor implements only __get__. Data descriptors take priority over instance dictionaries during attribute lookup, which is why property works as expected. Non-data descriptors can be overridden by instance attributes, which is how bound methods allow per-instance method reassignment.

Conclusion

Descriptors are the mechanism behind many Python features you use daily. Properties, class methods, static methods, and slots are all built on the descriptor protocol. Understanding descriptors gives you the power to create reusable attribute management that works correctly with the full Python attribute lookup system.