Skip to main content

Python Descriptors: The Secret Behind @property and ORM Fields

Advanced30 min6 exercises120 XP
0/6 exercises

When you use @property in Python, something interesting happens behind the scenes. The attribute doesn't just sit there waiting to be read — it intercepts access and runs custom code. How does that work?

The answer is descriptors. Think of them as "smart attributes" — attributes that can detect when they're being read, written to, or deleted, and run custom logic in response. They're the mechanism behind @property, @classmethod, @staticmethod, and even regular method calls.

Once you understand descriptors, you'll see them everywhere in Python. They're one of the most elegant patterns in the language, and they unlock powerful abilities like type validation, lazy loading, and computed properties.

What Makes Something a Descriptor?

A descriptor is any object that defines at least one of these three methods:

  • __get__(self, obj, objtype=None) — called when the attribute is read
  • __set__(self, obj, value) — called when the attribute is written to
  • __delete__(self, obj) — called when the attribute is deleted
  • This trio is called the descriptor protocol.

    A simple descriptor that logs access
    Loading editor...

    Every time you read or write u.name, Python doesn't just store the value directly. Instead, it calls the descriptor's __get__ or __set__ method. The descriptor acts as a gatekeeper for that attribute.

    What Is the Difference Between Data and Non-Data Descriptors?

    Descriptors come in two flavors:

  • Data descriptors define __set__ and/or __delete__. They always win over instance attributes.
  • Non-data descriptors only define __get__. Instance attributes can override them.
  • Data descriptors override instance attributes
    Loading editor...

    This distinction matters a lot. Regular methods are non-data descriptors (they only have __get__), which is why you can override a method on an instance. Properties are data descriptors, which is why they always intercept access.

    How Does @property Actually Work Under the Hood?

    The built-in property class is just a descriptor. When you write @property, Python creates a descriptor object that stores your getter, setter, and deleter functions. Here's a simplified version:

    Building your own @property
    Loading editor...

    The key insight: @MyProperty replaces the method with a descriptor instance. When you access c.radius, Python sees the descriptor and calls __get__, which in turn calls your original getter function.

    What Are Some Practical Uses for Descriptors?

    Descriptors shine when you need the same validation or transformation logic on multiple attributes. Instead of writing a @property for each one, you write one descriptor class and reuse it.

    Type-checking descriptor
    Loading editor...

    Notice __set_name__ — this is a Python 3.6+ feature that automatically tells the descriptor what attribute name it was assigned to. Before this existed, you had to pass the name manually (like TypeChecked('name', str)).

    Lazy-loading cached descriptor
    Loading editor...

    How Does __set_name__ Help Descriptors?

    Before Python 3.6, descriptors had an annoying problem: they didn't know their own name. You had to write redundant code like name = TypeChecked('name', str) — repeating the attribute name.

    __set_name__(self, owner, name) is called automatically when the class is created. It receives the owning class and the attribute name, so the descriptor can configure itself:

    Before __set_name__ (Python < 3.6)
    class Validated:
        def __init__(self, name, validator):
            self.name = name  # Must pass manually
            self.validator = validator
    
    class Form:
        email = Validated('email', is_email)  # Redundant!
    With __set_name__ (Python 3.6+)
    class Validated:
        def __init__(self, validator):
            self.validator = validator
        def __set_name__(self, owner, name):
            self.name = name  # Automatic!
    
    class Form:
        email = Validated(is_email)  # Clean!

    Practice Exercises

    Build a Simple Descriptor
    Write Code

    Create a descriptor class called Positive that only allows positive numbers to be set. If someone tries to set a non-positive value, raise a ValueError with the message '{name} must be positive'.

    Use it in a Rectangle class with width and height attributes. Create a Rectangle, set width=5 and height=3, then print both values.

    Loading editor...
    Predict the Descriptor Output
    Predict Output

    What does the following code print?

    class Desc:
        def __get__(self, obj, objtype=None):
            return 'descriptor'
    
    class MyClass:
        x = Desc()
    
    obj = MyClass()
    obj.__dict__['x'] = 'instance'
    print(obj.x)

    Think carefully: is Desc a data descriptor or a non-data descriptor?

    Loading editor...
    Build a TypeChecked Descriptor with __set_name__
    Write Code

    Create a descriptor TypeChecked that:

    1. Accepts an expected_type in __init__

    2. Uses __set_name__ to learn its attribute name automatically

    3. Raises TypeError with '{name} must be {type_name}' on invalid assignment

    Use it in a Config class with host (str) and port (int). Set host='localhost' and port=8080, then print them.

    Loading editor...
    Fix the Shared State Bug
    Fix the Bug

    This descriptor has a common bug — all instances share the same value! Fix it so each instance has its own independent value.

    Expected output:

    Alice
    Bob
    Loading editor...
    Build a Cached Property Descriptor
    Write Code

    Create a non-data descriptor called CachedProperty that works as a decorator. It should:

    1. Compute the value by calling the decorated function on first access

    2. Store the result in the instance's __dict__ so it's only computed once

    3. Use __set_name__ to learn the attribute name

    Use it in a Stats class:

    class Stats:
        def __init__(self, numbers):
            self.numbers = numbers
        
        @CachedProperty
        def total(self):
            print('computing')
            return sum(self.numbers)

    Create Stats([10, 20, 30]), access total twice, and print the result both times.

    Loading editor...
    Build a Range-Validated Descriptor
    Write Code

    Create a descriptor InRange that accepts min_val and max_val parameters and ensures the value stays within that range. Raise ValueError with '{name} must be between {min} and {max}' for out-of-range values.

    Use it in a GameCharacter class:

  • health = InRange(0, 100)
  • level = InRange(1, 99)
  • Create a character, set health=75 and level=10, then print both.

    Loading editor...