Skip to content
aviral gupta

// A1.5 · ~30 min · Advanced

Descriptors and properties

After this lesson you can write reusable managed attributes as descriptors, predict which of descriptor and instance dictionary wins a lookup, and explain what property really is.

Lesson 5 of 6 in A1 The data model

You will be able to

  • Write a descriptor with __get__, __set__ and __set_name__ that stores values per instance
  • Tell data from non-data descriptors and predict which one the instance dictionary overrides
  • Explain property as a data descriptor and use its getter and setter correctly
  1. Warm-up · Activity 1 of 7

    Warm-up from module I2: an instance assigns an attribute that the class also has. What does this print?

    class Box:
        size = 1
    
    
    b = Box()
    b.size = 2
    print(b.size, Box.size)
  2. Predict · Activity 2 of 7

    Predict before you read on: the class attribute x is an object with a __get__ method. What does this print?

    class Ten:
        def __get__(self, instance, owner):
            return 10
    
    
    class A:
        x = Ten()
    
    
    print(A().x, A.x)
  3. Practice · Activity 3 of 7

    Fill in the hook that Python calls when the class P is created, so that the Field learns it was assigned to age.

    class Field:
        def ____(self, owner, name):
            self.private = "_" + name
    def (self, owner, name):
  4. Practice · Activity 4 of 7

    Both defines __get__ and __set__. What does this print?

    class Both:
        def __get__(self, instance, owner):
            return "descriptor"
    
        def __set__(self, instance, value):
            pass  # ignores the value
    
    
    class A:
        x = Both()
    
    
    a = A()
    a.x = "instance"
    a.__dict__["x"] = "dict"
    print(a.x, a.__dict__["x"])
  5. Practice · Activity 5 of 7

    Match each term to what it means.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The instance dictionary gets an entry with the same name as a read-only property. What does a.x give?

    class A:
        @property
        def x(self):
            return "property"
    
    
    a = A()
    a.__dict__["x"] = "dict"
    print(a.x)
  7. Apply · Activity 7 of 7

    Mini-task. Write a descriptor Typed(kind) that only accepts values of the given type and raises TypeError with the message "<name> must be <type name>" otherwise. Use it for two attributes of a class Order: qty = Typed(int) and note = Typed(str).

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A validating descriptor next to a property

Positive checks every assignment to price and qty, for any class that uses it. __set_name__ gives each instance its own storage name, so vars(item) shows _price and _qty. total is a read-only property computed from both. Try setting item.price = -1, or reading LineItem.price.

main.py

from typing import Any


class Positive:
    """A data descriptor that only accepts numbers greater than zero."""

    def __set_name__(self, owner: type, name: str) -> None:
        self.name = name
        self.private = "_" + name

    def __get__(self, instance: object, owner: type | None = None) -> Any:
        if instance is None:
            return self  # accessed on the class: LineItem.price
        return getattr(instance, self.private)

    def __set__(self, instance: object, value: float) -> None:
        if value <= 0:
            raise ValueError(f"{self.name} must be > 0, got {value}")
        setattr(instance, self.private, value)


class LineItem:
    price = Positive()
    qty = Positive()

    def __init__(self, name: str, price: float, qty: int) -> None:
        self.name = name
        self.price = price  # calls Positive.__set__
        self.qty = qty

    @property
    def total(self) -> float:
        return self.price * self.qty


item = LineItem("tea", 4.5, 2)
print(item.total)
print(vars(item))
try:
    item.qty = 0
except ValueError as err:
    print(err)
print(type(LineItem.__dict__["price"]).__name__, type(LineItem.__dict__["total"]).__name__)

Run it with

python main.py

Output

9.0
{'name': 'tea', '_price': 4.5, '_qty': 2}
qty must be > 0, got 0
Positive property
  • self.price = price in __init__ already goes through Positive.__set__, so even the constructor is checked.
  • The two Positive objects are shared by every LineItem; only the values in the instance dictionary differ.
  • The class dictionary holds a Positive object and a property object: both are descriptors.
  • The type hint Any on __get__ keeps plain mypy quiet about returning either the descriptor or a value. mypy --strict, taught in module A2, still flags total, which returns that Any as a float: [no-any-return].
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

A range-checked attribute

Finish the descriptor Bounded(low, high). __set_name__ stores values under "_" + name, __get__ returns the descriptor itself on class access and the stored value otherwise, and __set__ raises ValueError for values outside low..high (inclusive). Thermostat uses it twice with different ranges.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Save both the name (for messages) and the private name "_" + name in __set_name__.

  2. Hint 2

    In __get__, check instance is None first; otherwise use getattr(instance, self.private).

  3. Hint 3

    In __set__, test self.low <= value <= self.high before calling setattr(instance, self.private, value).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from typing import Any


class Bounded:
    """A number that must stay between low and high (inclusive)."""

    def __init__(self, low: float, high: float) -> None:
        self.low = low
        self.high = high

    def __set_name__(self, owner: type, name: str) -> None:
        self.name = name
        self.private = "_" + name

    def __get__(self, instance: object, owner: type | None = None) -> Any:
        if instance is None:
            return self
        return getattr(instance, self.private)

    def __set__(self, instance: object, value: float) -> None:
        if not self.low <= value <= self.high:
            raise ValueError(f"{self.name} must be between {self.low} and {self.high}")
        setattr(instance, self.private, value)


class Thermostat:
    target = Bounded(5, 30)
    eco = Bounded(5, 20)

    def __init__(self, target: float, eco: float) -> None:
        self.target = target
        self.eco = eco


if __name__ == "__main__":
    t = Thermostat(21, 17)
    print(t.target, t.eco)
    try:
        t.target = 40
    except ValueError as err:
        print(err)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from typing import Any


class Bounded:
    """A number that must stay between low and high (inclusive)."""

    def __init__(self, low: float, high: float) -> None:
        self.low = low
        self.high = high

    # Add __set_name__: remember the name and store values under "_" + name.
    # Add __get__: return the descriptor itself on class access, else the value.
    # Add __set__: raise ValueError outside low..high, else store the value.


class Thermostat:
    target = Bounded(5, 30)
    eco = Bounded(5, 20)

    def __init__(self, target: float, eco: float) -> None:
        self.target = target
        self.eco = eco


if __name__ == "__main__":
    t = Thermostat(21, 17)
    print(t.target, t.eco)
    try:
        t.target = 40
    except ValueError as err:
        print(err)

test_main.py

from main import Bounded, Thermostat


def test_get_and_set():
    """Values can be read and changed inside the range"""
    t = Thermostat(21, 17)
    t.target = 25
    assert (t.target, t.eco) == (25, 17), f"got {(t.target, t.eco)!r}, expected (25, 17)"


def test_rejects_out_of_range():
    """A value outside low..high raises ValueError"""
    t = Thermostat(21, 17)
    try:
        t.eco = 25
    except ValueError:
        assert t.eco == 17, f"after the failed assignment eco is {t.eco!r}, expected 17"
        return
    raise AssertionError("t.eco = 25 was accepted, but eco only allows 5 to 20")


def test_stored_under_private_names():
    """Each value lives in the instance dictionary under _ plus its name"""
    t = Thermostat(21, 17)
    assert vars(t) == {"_target": 21, "_eco": 17}, f"vars(t) is {vars(t)!r}"


def test_instances_are_independent():
    """Two thermostats keep their own values"""
    a = Thermostat(21, 17)
    b = Thermostat(10, 8)
    assert (a.target, b.target) == (21, 10), f"got {(a.target, b.target)!r}: are values stored on the descriptor?"


def test_class_access_returns_descriptor():
    """Thermostat.target gives the Bounded object itself"""
    assert isinstance(Thermostat.target, Bounded), f"Thermostat.target is {Thermostat.target!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A lazy attribute

lazy is a non-data descriptor used as a decorator. It already computes the value on each access. Make it compute only once: store the result in the instance dictionary under the attribute name. Because lazy has no __set__, the next lookup finds that entry first and never calls __get__ again.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    __set_name__ already saves the attribute name in self.name.

  2. Hint 2

    instance.__dict__[self.name] = value writes straight into the instance dictionary, without any descriptor involved.

  3. Hint 3

    Do not add __set__: that would make lazy a data descriptor, and the instance dictionary entry would be ignored.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from collections.abc import Callable
from typing import Any


class lazy:
    """Computes the value on first access and caches it on the instance."""

    def __init__(self, func: Callable[[Any], Any]) -> None:
        self.func = func

    def __set_name__(self, owner: type, name: str) -> None:
        self.name = name

    def __get__(self, instance: object, owner: type | None = None) -> Any:
        if instance is None:
            return self
        value = self.func(instance)
        instance.__dict__[self.name] = value
        return value


class Report:
    def __init__(self, rows: list[int]) -> None:
        self.rows = rows
        self.runs = 0

    @lazy
    def total(self) -> int:
        self.runs += 1
        return sum(self.rows)


if __name__ == "__main__":
    report = Report([3, 4, 5])
    print(report.total, report.total, report.runs)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from collections.abc import Callable
from typing import Any


class lazy:
    """Computes the value on first access and caches it on the instance."""

    def __init__(self, func: Callable[[Any], Any]) -> None:
        self.func = func

    def __set_name__(self, owner: type, name: str) -> None:
        self.name = name

    def __get__(self, instance: object, owner: type | None = None) -> Any:
        if instance is None:
            return self
        # Store the value so that later lookups never reach __get__ again.
        return self.func(instance)


class Report:
    def __init__(self, rows: list[int]) -> None:
        self.rows = rows
        self.runs = 0

    @lazy
    def total(self) -> int:
        self.runs += 1
        return sum(self.rows)


if __name__ == "__main__":
    report = Report([3, 4, 5])
    print(report.total, report.total, report.runs)

test_main.py

from main import Report, lazy


def test_value():
    """total is the sum of the rows"""
    assert Report([1, 2, 3]).total == 6, f"total is {Report([1, 2, 3]).total!r}, expected 6"


def test_computed_once():
    """The function runs only on the first access"""
    report = Report([1, 2])
    for _ in range(3):
        report.total
    assert report.runs == 1, f"the function ran {report.runs} times, expected once"


def test_cached_in_instance_dict():
    """After the first access the value sits in the instance dictionary"""
    report = Report([4])
    report.total
    assert vars(report).get("total") == 4, f"vars(report) is {vars(report)!r}"


def test_uses_the_attribute_name():
    """The cache key is the name the descriptor was assigned to"""
    class Box:
        @lazy
        def size(self):
            return 7

    box = Box()
    box.size
    assert vars(box) == {"size": 7}, f"vars(box) is {vars(box)!r}, expected {{'size': 7}}"


def test_class_access_returns_descriptor():
    """Report.total gives the lazy object itself"""
    assert isinstance(Report.total, lazy), f"Report.total is {Report.total!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

A property that reads itself

class Product:
    @property
    def price(self):
        return self.price


print(Product().price)

What Python prints

RecursionError: maximum recursion depth exceeded

Why, and the fix

Inside the getter, self.price is the property again, so it calls itself until the stack runs out. Keep the value under a different name, such as self._price, and read that in the getter.

A setter with a different name

class Product:
    def __init__(self, price):
        self.price = price

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

    @price.setter
    def set_price(self, value):
        self._price = value


Product(5)

What Python prints

AttributeError: property 'price' of 'Product' object has no setter

Why, and the fix

@price.setter returns a new property that has the setter, and the def binds it to the name set_price. The name price still holds the old, read-only property. Give the setter the same name as the getter: def price(self, value).

__get__ without the class-access case

class Field:
    def __set_name__(self, owner, name):
        self.private = "_" + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private)

    def __set__(self, instance, value):
        setattr(instance, self.private, value)


class P:
    age = Field()


print(P.age)

What Python prints

AttributeError: 'NoneType' object has no attribute '_age'

Why, and the fix

On class access, Python calls __get__ with instance set to None. Start __get__ with if instance is None: return self, so that P.age gives the descriptor, as help() and other tools expect.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

An object in the class that answers for an attribute

A descriptor is an object stored as a class attribute whose class defines __get__, __set__ or __delete__. Reading obj.x calls __get__(obj, type(obj)); reading Cls.x calls __get__(None, Cls), so return self in that case. __set_name__(owner, name) runs once when the owner class is created and tells the descriptor its attribute name. The descriptor is shared by all instances, so store each value on the instance, for example under "_" + name.

Data versus non-data

A descriptor that defines __set__ or __delete__ is a data descriptor; one with only __get__ is a non-data descriptor. For obj.x, Python checks data descriptors first, then the instance dictionary, then non-data descriptors and plain class variables. So a data descriptor cannot be shadowed by obj.__dict__, but a non-data one can. Functions are non-data descriptors: their __get__ returns a bound method, which is where self comes from.

property is a data descriptor

property(fget, fset, fdel) builds a data descriptor that calls your functions. @property makes the getter; @name.setter returns a copy of the property with a setter added, so the setter must reuse the same name. Without a setter, assignment raises AttributeError. Use property for one attribute of one class; write a descriptor class when the same rule applies to many attributes.

Sources

Last reviewed September 29, 2026