šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Python Descriptors

__get__, __set__, and __delete__ — the low-level protocol that powers @property, ORMs, and validated attributes across the Python ecosystem.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

Where must a descriptor object be assigned for the protocol to activate?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

Descriptors are the least-known and most powerful mechanism behind Python's attribute access. @property is a descriptor. Django and SQLAlchemy model fields are descriptors. Once you understand the protocol, you understand what's actually happening every time you write obj.attribute.

1What Actually Happens on obj.attribute

When you write p.price, Python doesn't simply look up "price" in p.__dict__ and stop. The actual algorithm (implemented in object.__getattribute__) checks the *class* (and its base classes, via MRO) for an attribute named price first; if that class-level attribute is a data descriptor — an object implementing both __get__ and __set__ (or __delete__) — Python calls its __get__ method and returns that result *even if* the instance also has its own price entry in p.__dict__. Only if there's no matching data descriptor on the class does Python fall back to checking the instance's own __dict__ directly, and only after that does it check for a non-data descriptor (one with just __get__) on the class.

This is precisely how PositiveNumber, assigned as price = PositiveNumber() inside the Product class body, intercepts every p.price and p.price = value on every instance of Product: the descriptor lives once on the class, but __get__/__set__ receive instance as an argument, so it can read and write a per-instance value (stored under a different name, _price, to avoid infinite recursion) while still centralizing the validation logic in one place.

__set_name__, called automatically when the class body finishes executing, tells the descriptor what name it was assigned to ("price"), which is how PositiveNumber derives its private storage attribute name ("_price") without you needing to pass it explicitly.

āœ•
—
+
class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.name)

    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError(f"{self.name} must be positive")
        setattr(instance, self.name, value)
localhost:3000
Attribute Access Flow
p.price
Checks class for a data descriptor first → calls __get__(p, Product)

2Data Descriptors vs Non-Data Descriptors

The distinction between a data descriptor (has __set__ and/or __delete__, in addition to __get__) and a non-data descriptor (has only __get__) governs priority when an instance attribute of the same name also exists. A data descriptor always wins over an instance's own __dict__ entry — this is deliberate and is exactly what makes @property-based validation impossible to bypass by simply assigning instance.__dict__['price'] = -5 through some other code path, since that route is never even checked when a data descriptor is present.

A non-data descriptor, by contrast, only applies when the instance itself has no same-named entry in its own __dict__ — this is precisely how *methods* work. A plain function defined in a class body is a non-data descriptor (functions implement __get__, returning a bound method when accessed through an instance), which is why instance.some_attribute = some_value can shadow a method of the same name on that specific instance, while the same trick can never shadow a @property.

This two-tier priority (data descriptor > instance __dict__ > non-data descriptor > class __dict__ fallback) is the complete algorithm behind every obj.attribute lookup in Python, and understanding it explains behavior that otherwise looks like special-cased magic — why properties can't be overridden per-instance, why methods can, and why self.__dict__ sometimes 'wins' and sometimes doesn't.

āœ•
—
+
class Product:
    price = PositiveNumber()  # descriptor lives on the CLASS

    def __init__(self, price: float):
        self.price = price   # triggers __set__

p = Product(19.99)
print(p.price)   # triggers __get__ -> 19.99
p.price = -5     # triggers __set__ -> raises ValueError
localhost:3000
Descriptor Priority
Data descriptor always wins over instance __dict__; non-data descriptor loses to it

3@property Is Just a Built-In Descriptor

property is not special syntax handled by the interpreter — it's an ordinary built-in class, implementing exactly the same __get__/__set__/__delete__ protocol as the hand-written PositiveNumber class. @property above a method definition is sugar for calling property(fget=that_method), and @price.setter above a second method named price returns a *new* property object combining the original getter with the new setter, reassigned back onto the name price in the class namespace.

This equivalence means everything covered about descriptor priority applies identically to @property: a property is a data descriptor (it has __set__, even a property with no explicit setter defines one that raises AttributeError on assignment), so it always takes precedence over anything you might try to put in instance.__dict__ under the same name — which is exactly why p.price = -5 raises ValueError from your custom setter logic rather than silently succeeding.

The practical decision between @property and a hand-written descriptor class comes down to reuse: @property is ideal for a single attribute on a single class, while a custom descriptor class like PositiveNumber is worth writing once you need the *same* validation logic (positive numbers, non-empty strings, bounded ranges) reused across multiple attributes or multiple classes — write the validation once as a descriptor, then assign instances of it to as many class attributes as needed.

āœ•
—
+
class Product:
    def __init__(self, price: float):
        self._price = price

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError("price must be positive")
        self._price = value
localhost:3000
Equivalence
@property def price(self): ...
Sugar for price = property(fget=..., fset=...)

4Step-by-Step Breakdown

obj.attribute isn't always 'just' a dictionary lookup. Descriptors let a class intercept and control attribute access entirely — this is how @property really works under the hood.

A descriptor is any object implementing __get__ (and optionally __set__/__delete__), assigned as a CLASS attribute — not an instance attribute.

Assign the descriptor as a class attribute, and every instance's attribute access is routed through __get__/__set__ automatically.

Checkpoint: Where must a descriptor object be assigned for the protocol to activate?

  • →As a class attribute (e.g. price = PositiveNumber() inside the class body)
  • →As an instance attribute, inside __init__

@property is literally a built-in descriptor factory — this hand-rolled version behaves identically to using @property with a setter.

Checkpoint: Is @property built on top of the descriptor protocol, or is it a separate, unrelated mechanism?

  • →property is itself a descriptor — @property is literally a descriptor factory
  • →It is a completely separate mechanism from descriptors

Descriptors intercept individual attribute access — magic methods generalize that same idea to nearly every operator and built-in behavior an object can have.

Validate with a Real Descriptor. Finish PositiveNumber.__set__(): a descriptor's __set__ runs automatically on every assignment.

Level Up šŸš€

Advanced cheat sheets, SEO tricks, and interview prep for this topic.

Browser Support

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

Fully supported (via server-side Python execution).

Best Practices

Use @property for one-off validated attributes; write a reusable descriptor class for repeated validation logic

Copy-pasting the same @property getter/setter pair across five classes is exactly the duplication a small descriptor class (like PositiveNumber) eliminates by centralizing the logic once.

Always implement __set_name__ on custom descriptors instead of hardcoding the storage attribute name

Hardcoding a private name inside __init__ breaks the moment the same descriptor class is reused for a differently-named attribute; __set_name__ derives it automatically and correctly from wherever the descriptor is assigned.

Frequent Bugs

THE BUG

Storing the value directly as self.name = value inside a descriptor's __set__ using the PUBLIC attribute name, causing infinite recursion (setting price calls __set__, which sets price again, which calls __set__...).

THE FIX

Store the actual value under a different, private attribute name (e.g. an underscore-prefixed name derived via __set_name__) on the instance, never under the exact same public name the descriptor itself is exposed as.

Real-World Examples

A Reusable Validated-String Descriptor

Multiple model classes (User, Product, Order) each need a "non-empty string" field with identical validation logic, without copy-pasting the same @property getter/setter into every class.

class NonEmptyString:
    def __set_name__(self, owner, name):
        self.name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.name)

    def __set__(self, instance, value):
        if not value or not value.strip():
            raise ValueError(f"{self.name} cannot be empty")
        setattr(instance, self.name, value)

class User:
    username = NonEmptyString()
    email = NonEmptyString()

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Writing a descriptor's __set__ as setattr(instance, PUBLIC_NAME, value) using the same name the descriptor itself is assigned to, causing infinite recursion.

# Wrong: infinite recursion class PositiveNumber: def __set__(self, instance, value): instance.price = value # triggers __set__ again! # Correct: distinct private storage name class PositiveNumber: def __set_name__(self, owner, name): self.name = f"_{name}" def __set__(self, instance, value): setattr(instance, self.name, value) # stores as _price

The Solution //

Store under a distinct private name (commonly derived via __set_name__, like "_price" for a descriptor named "price"), never the exact same public attribute name.

Lesson Glossary

[01]Descriptor

Any object implementing __get__ (and optionally __set__/__delete__), assigned as a class attribute, that intercepts attribute access on instances of that class.

Code Preview
// Descriptor context

[02]Data descriptor

A descriptor implementing __set__ and/or __delete__ in addition to __get__; always takes priority over an instance's own __dict__ entry.

Code Preview
// Data descriptor context

[03]Non-data descriptor

A descriptor implementing only __get__ (e.g. plain functions/methods); loses priority to a same-named instance __dict__ entry.

Code Preview
// Non-data descriptor context

[04]__set_name__

A descriptor method automatically called with the owning class and attribute name when the class body finishes executing, used to derive a private storage name.

Code Preview
// __set_name__ context

Continue Learning