Properties & Descriptors

Reviewed & published by Brayan K

A property is a method dressed up as an attribute: you write obj.area with no parentheses, but a function runs behind the scenes to compute it, validate it, or guard it — giving you clean attribute syntax with full control.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

Properties let you add computed values and validation without breaking the simple obj.x interface your callers expect. Descriptors take the idea one level deeper, letting you reuse that managed-attribute logic across many fields and classes.

What You'll Learn in This Lesson

1 Computed Attributes with @property

Decorate a method with @property and you access it like an attribute — no parentheses. The value is computed fresh each time from the data you already store:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def diameter(self):
        return self.radius * 2

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

c = Circle(5)
print(c.diameter)        # 10   (no parentheses!)
print(round(c.area, 2))  # 78.54

c.radius = 10            # change the source...
print(c.diameter)        # 20   (...the property follows)
c.diameter()   <- a normal method needs ()
c.diameter     <- a @property is accessed like data

Same logic, friendlier interface. diameter always reflects the current radius because it's recomputed on every access.

🛡️ Validation with a Setter

Add a @<name>.setter method and you can run code — including validation — every time someone assigns to the attribute. The convention is to store the real value in a "private" field like _celsius:

class Temperature:
    def __init__(self, celsius=0):
        self.celsius = celsius          # goes through the setter

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Below absolute zero!")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.fahrenheit)      # 77.0
t.celsius = 100          # validated, then stored
print(t.fahrenheit)      # 212.0

t.celsius = -300         # ValueError: Below absolute zero!

Notice the getter returns self._celsius while the setter writes self._celsius. The public name celsius is the property; the underscore name holds the actual data.

2 Getter, Setter & Deleter — the Full Trio

A property can have all three hooks. The @<name>.deleter runs when someone uses del obj.attr — handy for cleanup or to forbid deletion entirely:

class Profile:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value.strip().title()

    @name.deleter
    def name(self):
        print("Clearing name")
        self._name = "anonymous"

p = Profile("  ada  ")
print(p.name)       # Ada   (setter cleaned it up)
del p.name          # Clearing name
print(p.name)       # anonymous
class Account:
    def __init__(self, balance=0):
        self.balance = balance          # routed through the setter

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

    @property
    def is_overdraft_safe(self):        # computed, read-only
        return self._balance >= 100

acct = Account(50)
print("Balance:", acct.balance)
print("Safe?", acct.is_overdraft_safe)

acct.balance = 250
print("Safe?", acct.is_overdraft_safe)

try:
    acct.balance = -10
except ValueError as e:
    print("Rejected:", e)

# ✅ Expected output:
# Balance: 50
# Safe? False
# Safe? True
# Rejected: Balance cannot be negative

3 The Descriptor Protocol

A property validates one attribute on one class. When you want to reuse the same validation across many attributes, write a descriptor: a class with __get__ and __set__ methods, stored as a class attribute. @property is itself a descriptor under the hood.

class Positive:
    """A reusable descriptor: the value must be > 0."""
    def __set_name__(self, owner, name):
        self.storage = "_" + name        # remember where to store it

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.storage)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"must be positive, got {value}")
        setattr(obj, self.storage, value)

class Order:
    quantity = Positive()                # reuse the same logic...
    price = Positive()                   # ...on multiple fields

    def __init__(self, quantity, price):
        self.quantity = quantity
        self.price = price

o = Order(3, 9.99)
print(o.quantity, o.price)   # 3 9.99

o.quantity = -1              # ValueError: must be positive, got -1

⚡ cached_property — Compute Once, Reuse Forever

A plain @property recomputes on every access. For an expensive calculation that won't change, functools.cached_property runs the body once, stores the result in the instance, and returns the cached value thereafter:

from functools import cached_property

class Dataset:
    def __init__(self, numbers):
        self.numbers = numbers

    @cached_property
    def total(self):
        print("  (computing total...)")    # proves it runs once
        return sum(self.numbers)

d = Dataset([10, 20, 30, 40])
print(d.total)     #   (computing total...)  then  100
print(d.total)     # 100   <- cached, no recompute
print(d.total)     # 100

🎯 Your Turn: Fill in the Blank

Complete the property so celsius stays read-only and fahrenheit is computed. Replace each ___, then run it.

# 🎯 YOUR TURN: replace each ___
class Weather:
    def __init__(self, celsius):
        self._celsius = celsius

    @___                          # decorator that makes a property
    def celsius(self):
        return self.___           # return the stored value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + ___   # the constant for F

w = Weather(20)
print(w.celsius)
print(w.fahrenheit)

# Expected output:
# 20
# 68.0
class Weather:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

w = Weather(20)
print(w.celsius)      # 20
print(w.fahrenheit)   # 68.0

Common Errors

❌ Infinite recursion in the getter

@property
def celsius(self):
    return self.celsius     # calls the property again -> crash!
# RecursionError: maximum recursion depth exceeded

✅ Store the real value under a different name and return that:

@property
def celsius(self):
    return self._celsius    # underscore = the actual storage

❌ Calling a property like a method

c = Circle(5)
print(c.area())   # TypeError: 'float' object is not callable

✅ A property is accessed without parentheses: c.area.

❌ Assigning to a read-only property

c.diameter = 20   # AttributeError: property has no setter

✅ Add a @diameter.setter if assignment should be allowed — otherwise it's read-only by design.

🎯 Mini Challenge: A Validated Rectangle

Build a Rectangle whose width and height reject non-positive values via setters, and whose area and perimeter are computed properties.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("width must be positive")
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("height must be positive")
        self._height = value

    @property
    def area(self):
        return self._width * self._height

    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

r = Rectangle(4, 6)
print("Area:", r.area)
print("Perimeter:", r.perimeter)
r.width = 10
print("New area:", r.area)
try:
    r.height = 0
except ValueError as e:
    print("Rejected:", e)

# ✅ Expected output:
# Area: 24
# Perimeter: 20
# New area: 60
# Rejected: height must be positive

📚 Keep Learning

Frequently Asked Questions

Lesson complete — your attributes are smart now!

You can expose computed values with @property, validate assignments with a setter, hook deletion with a deleter, reuse logic across fields with the descriptor protocol, and cache expensive results with cached_property — all while keeping the clean obj.x interface.

Practice quiz

How do you access a method decorated with @property?

  • With parentheses, like obj.area()
  • With brackets, like obj[area]
  • Without parentheses, like obj.area
  • Only via getattr(obj, 'area')

Answer: Without parentheses, like obj.area. A @property is accessed like an attribute (obj.area) — no parentheses — even though a function runs behind the scenes.

A @property like 'diameter' returning self.radius * 2 is recomputed when?

  • Every time you access it
  • Once, at object creation
  • Only when you call a refresh method
  • Never — it caches the first value

Answer: Every time you access it. A plain @property recomputes its value fresh on every access, so it always reflects the current source data.

What does adding a @<name>.setter let you do?

  • Make the property faster
  • Delete the attribute
  • Turn the property into a classmethod
  • Run code (e.g. validation) every time someone assigns to the attribute

Answer: Run code (e.g. validation) every time someone assigns to the attribute. A setter runs on assignment, letting you validate or transform the value before storing it.

Why does the getter return self._celsius instead of self.celsius?

  • Underscore names are faster
  • Returning self.celsius would call the property again, causing infinite recursion
  • self.celsius does not exist
  • It is just a style preference

Answer: Returning self.celsius would call the property again, causing infinite recursion. Returning self.celsius inside the getter re-invokes the property endlessly — a RecursionError. Store the real value under a different name like _celsius.

In a property, which hook must be defined first?

  • The getter (bare @property)
  • The setter
  • The deleter
  • Order does not matter

Answer: The getter (bare @property). The getter with bare @property must come first; the setter and deleter attach to it by the same name.

A descriptor is a class that defines which methods and lives where?

  • __init__, on the instance
  • __call__, in a module
  • __get__/__set__/__delete__, stored as a class attribute
  • __str__, on a metaclass

Answer: __get__/__set__/__delete__, stored as a class attribute. A descriptor defines __get__, __set__, or __delete__ and must be stored as a class attribute, not on the instance.

What does __set_name__ tell a descriptor?

  • The instance's id
  • Which attribute name it was assigned to
  • The owner module
  • Nothing — it is deprecated

Answer: Which attribute name it was assigned to. __set_name__(self, owner, name) tells the descriptor which attribute name it was bound to, so one descriptor class can guard many fields.

How does functools.cached_property differ from @property?

  • It recomputes more often
  • It cannot be read more than once
  • It only works on functions
  • It computes once on first access, then returns the stored value

Answer: It computes once on first access, then returns the stored value. cached_property computes the value once, stores it in the instance __dict__, and returns the cached value on every later access.

Which feature does cached_property NOT work with?

  • Classes with __init__
  • Classes using __slots__
  • Subclasses
  • Classes with other properties

Answer: Classes using __slots__. cached_property needs a writable __dict__ to store its result, so it does not work on classes that use __slots__.

What happens if you assign to a read-only property with no setter?

  • It silently succeeds
  • It creates a new instance attribute
  • It raises AttributeError
  • It calls the getter

Answer: It raises AttributeError. Assigning to a property that has no setter raises AttributeError — it is read-only by design until you add a setter.

Continue this course

Frequently asked questions

What does @property actually do?

@property turns a method into a managed attribute — you access it like obj.area (no parentheses) but a function runs behind the scenes. It lets you expose computed values and add validation while keeping a clean attribute-style interface, so callers never have to know whether something is stored or calculated.

When should I use a property instead of a plain attribute?

Start with a plain attribute. Reach for a property the moment you need to compute a value from other data, validate what gets assigned, make something read-only, or run side effects on access. The big win is that you can convert a plain attribute into a property later without changing any calling code.

What is the descriptor protocol?

A descriptor is any object that defines __get__, __set__, or __delete__ and is stored as a class attribute. When you access that attribute on an instance, Python calls those methods instead of returning the object directly. @property is itself a descriptor; writing your own lets you reuse the same validation logic across many attributes and classes.

How is functools.cached_property different from @property?

@property recomputes every single time you access it. functools.cached_property computes the value once on first access, stores it in the instance's __dict__, and returns the cached value on every later access. Use it for expensive calculations whose result won't change for the life of the object.