Filter concepts by levelShowing all levels.

Python · Object-Oriented Python

Properties

Concepts
4

How @property turns a method into an attribute-style read, how a setter and deleter make it writable and deletable, and why deriving a value on every read means it can never go stale.

This section

The property triad, and deriving values

The getter, the optional setter and deleter that pair with it, and the most common reason to reach for any of it.

@property

corebeginner

@property turns a method into something read like a plain attribute — obj.value instead of obj.value(). The method still runs every time the attribute is read, so it can compute, validate, or look something up on the fly.

Think of it as

A property is a light switch, not a note taped to the wall — flipping it (reading obj.value) triggers real wiring behind the panel every time, even though it looks exactly like reading a fixed label. A plain attribute IS the note; a property is the switch that runs code and hands back whatever it decides to.

python
class Name:
    @property
    def value(self):
        return self._value      # computed, validated, or looked up

What we're doing: Expose a computed value (area) as a read-only property, and show that reading it re-runs the calculation every time the underlying data changes.

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

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


c = Circle(2)
print(c.area)

c.radius = 4          # change the underlying data
print(c.area)          # area recomputes — nothing was cached
5
@property turns area into an attribute-style lookup — defined like a method, read without parentheses.
6
The body runs fresh every time c.area is read — this is a computed value, not stored state.
13
radius changes, and the next c.area read reflects it immediately, because the method reruns rather than returning a cached number.
Output
12.56636
50.26544

Why this works: c.area looks exactly like reading a stored attribute, but it is really calling the area method every single time — that is what makes the second print show a different, correctly recomputed number after c.radius changed, with no cache to invalidate and no extra call needed anywhere in the calling code.

Calling a property like a method, with parentheses

Wrong

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

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

c = Circle(2)
print(c.area())   # TypeError!

Better

python
c = Circle(2)
print(c.area)      # no parentheses — it's a property, not a method call

What you see: TypeError: 'float' object is not callable — because c.area already evaluated to a float before the () was even applied to it.

Why: @property's entire point is making a method readable with attribute syntax — c.area already runs the method and returns its result (a float here). Writing c.area() then tries to call that float as if it were a function, which floats do not support.

Reading a property runs a method, but looks like an attribute

obj.area

no parentheses, looks like an attribute

@property method runs

computes the value fresh, every read

returns a plain value

caller never sees the method call

  1. obj.area — no parentheses, looks like an attribute
  2. @property method runs — computes the value fresh, every read
  3. returns a plain value — caller never sees the method call

Plain attribute vs. @property, from the caller's side

Plain attribute vs. @property, from the caller's side
Caller writesWhat actually happens
obj.name (plain attribute)reads a stored value directly, no code runs
obj.name (with @property)calls a method behind the scenes, returns its result
obj.name = x (no setter defined)AttributeError — read-only property
obj.name() wrong — a property is read without parentheses

Together

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

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

c = Circle(2)
print(c.area)       # no parentheses
print(c.radius)

Remember: @property makes a method readable as obj.name, no parentheses — it reruns on every read and is read-only unless a setter is added.

See also: property setters · computed properties · instance methods

Property setters

corebeginner

@name.setter defines what obj.name = value actually does — most often validating the incoming value before storing it. Without a setter, a @property is read-only; adding one makes assignment run real code instead of failing.

Think of it as

A setter is a bouncer at the door of an attribute, not the attribute itself — obj.name = value hands the new value to the bouncer first, who can reject it (raise an error), transform it, or let it through to storage. Without a bouncer, the door (the property) stays locked to writes entirely.

python
class Name:
    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, new_value):
        self._value = new_value    # validate/transform here

What we're doing: Add a setter that validates a value before storing it, so an invalid assignment raises immediately rather than silently corrupting state.

temperature.pypython
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @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


t = Temperature(20)
print(t.celsius)

t.celsius = 25
print(t.celsius)

try:
    t.celsius = -300
except ValueError as e:
    print("ValueError:", e)
3
self.celsius = celsius in __init__ already goes through the setter below — construction and later assignment share the same validation.
9
@celsius.setter reuses the property's own name — this is what pairs the two methods together.
11
Validation runs before storage — an invalid value never reaches self._celsius at all.
12
The actual data lives under a different name, _celsius, so the setter assigning to it does not recurse back into itself.
Output
20
25
ValueError: below absolute zero

Why this works: t.celsius = 25 succeeds because 25 passes the setter's validation and is stored in self._celsius. t.celsius = -300 fails because the setter checks value < -273.15 before storing anything — the ValueError is raised from inside the setter itself, so the previously valid self._celsius (25) is left completely untouched, not overwritten with a bad value.

Setter assigning to self.name instead of a differently-named attribute

Wrong

python
class Temperature:
    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        self.celsius = value   # calls the setter again -> infinite recursion!

t = Temperature()
t.celsius = 20   # RecursionError

Better

python
class Temperature:
    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        self._celsius = value   # different name — stores directly

t = Temperature()
t.celsius = 20   # works

What you see: RecursionError: maximum recursion depth exceeded — the setter calls itself indefinitely before ever actually storing anything.

Why: self.celsius = value inside the celsius setter is itself a property assignment, which calls the SAME setter again with the same value — there is no base case, so it recurses until Python's stack limit is hit. The setter must store the value under a genuinely different attribute name (self._celsius) to actually terminate.

t.celsius = -300 — the setter runs before storage
FalseTrue

t.celsius = -300

@celsius.setter runs

value = -300

value < -273.15?

self._celsius = value

ValueError raised

_celsius left untouched

  • t.celsius = -300
    • leads to @celsius.setter runs
  • @celsius.setter runs — value = -300
    • leads to value < -273.15?
  • value < -273.15?
    • leads to self._celsius = value (False)
    • on error, leads to ValueError raised (True)
  • self._celsius = value
  • ValueError raised — _celsius left untouched

Getter alone vs. getter + setter

Getter alone vs. getter + setter
DefinitionWhat obj.name = value does
Only @property (getter)AttributeError — no setter defined
@property + @name.setterruns the setter method with value as the argument
Setter raises inside its bodythe raised exception propagates — assignment never completes
Setter stores under a different name (self._name)avoids infinite recursion into the property itself

Together

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

    @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

t = Temperature(20)
print(t.celsius)

Remember: @name.setter must reuse the same method name as its @property — a mismatched name never attaches, and self.name inside it recurses forever.

See also: property decorator · property deleters · encapsulation

Property deleters

standardintermediate

@name.deleter defines what del obj.name actually does — the least common of the three property methods, used for cleanup, resetting to a default, or blocking deletion outright by raising inside it.

Think of it as

A deleter is a shredder next to the bouncer at the door — del obj.name hands the request to the shredder instead of the getter or setter, and the shredder decides what 'removing' this value actually means: wipe it, reset it to a default, or refuse the request entirely.

python
class Name:
    @property
    def value(self):
        return self._value

    @value.deleter
    def value(self):
        del self._value          # or reset, or raise to forbid it

What we're doing: Use a deleter to reset a cached value to None rather than truly removing the underlying attribute, so a later read recomputes it.

report.pypython
class Report:
    def __init__(self, rows):
        self.rows = rows
        self._total = None

    @property
    def total(self):
        if self._total is None:
            print("computing total...")
            self._total = sum(self.rows)
        return self._total

    @total.deleter
    def total(self):
        print("clearing cached total")
        self._total = None


r = Report([10, 20, 30])
print(r.total)
print(r.total)          # cached, no recompute

del r.total
print(r.total)          # recomputes after the deleter reset it
6
total is a computed, lazily-cached property — the first read computes and stores it, later reads reuse the stored value.
13
@total.deleter reuses the property's own name, the same rule the setter follows.
14
del r.total does not remove _total entirely — this deleter resets it to None so the next read recomputes.
Output
computing total...
60
60
clearing cached total
computing total...
60

Why this works: The first r.total prints "computing total..." because _total starts as None. The second r.total prints nothing extra — it reuses the cached value. del r.total runs the deleter, which resets _total to None rather than actually deleting the attribute, so the third r.total recomputes from scratch and prints "computing total..." again before returning the same 60.

Assuming del obj.name always removes the attribute, like it does for a plain attribute

Wrong

python
class Report:
    def __init__(self, rows):
        self.rows = rows
        self._total = sum(rows)

    @property
    def total(self):
        return self._total

    @total.deleter
    def total(self):
        self._total = None   # resets, does NOT remove _total

r = Report([1, 2, 3])
del r.total
print(r._total)   # None, not gone — _total still exists

Better

python
class Report:
    @total.deleter
    def total(self):
        del self._total   # actually removes it, if that's really the intent

# know which behaviour a given deleter implements before relying on it

What you see: r._total still exists (as None) after del r.total — a plain attribute deletion would have removed it entirely, but this deleter chose to reset instead.

Why: del obj.name calls whatever the deleter method's body actually does — Python does not enforce that a deleter must remove anything. A deleter resetting state to a default (as this cache-clearing example does deliberately) is common and valid; the mistake is assuming del always means 'gone' without checking what the specific deleter implements.

What del obj.name does, by definition

What del obj.name does, by definition
Definitiondel obj.name behaviour
@property only, no deleterAttributeError — cannot delete
@property + @name.deleterruns the deleter method
Deleter does del self._namegenuinely removes the underlying storage
Deleter raisesblocks deletion with a custom, explicit error

Together

python
class Account:
    def __init__(self, balance):
        self._balance = balance

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

    @balance.deleter
    def balance(self):
        raise AttributeError("cannot delete balance directly — close the account instead")

a = Account(100)
try:
    del a.balance
except AttributeError as e:
    print(e)

Remember: @name.deleter defines what del obj.name does — often a reset rather than a true removal. Without one, deleting a property raises AttributeError.

See also: property setters · property decorator · computed properties

Computed properties

standardbeginner

A computed property derives its value from other attributes every time it is read, instead of being stored directly — like full_name computed from first_name and last_name. It can never go stale, because there is nothing stored to go stale.

Think of it as

A computed property is a live currency-conversion display, not a price tag written in marker — the tag would need to be manually rewritten every time the exchange rate changes, and could easily be forgotten. The live display recalculates from the current rate on every glance, so it is structurally impossible for it to show a stale number.

python
class Person:
    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"   # derived, not stored

What we're doing: Show a computed property staying correct automatically after the data it depends on changes, contrasted with a plain attribute that would silently go stale.

person.pypython
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name
        self.full_name_cached = f"{first_name} {last_name}"   # stale-prone

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"           # always correct


p = Person("Ada", "Lovelace")
print(p.full_name)
print(p.full_name_cached)

p.last_name = "King"
print(p.full_name)            # correctly updated
print(p.full_name_cached)     # still says "Lovelace" — went stale
5
full_name_cached is computed ONCE, at construction time, and stored as a plain string.
7
full_name is a @property — it has no stored value of its own, so it recomputes from first_name/last_name on every read.
15
After last_name changes, full_name reflects it immediately; full_name_cached does not, because nothing told it to update.
Output
Ada Lovelace
Ada Lovelace
Ada King
Ada Lovelace

Why this works: full_name_cached was computed once in __init__ and never touched again, so it keeps showing the original name after last_name changes — it is a snapshot, not a live view. full_name has no such snapshot to go stale: every read re-runs its f-string against whatever first_name and last_name currently are, which is exactly why it reflects the update immediately with zero extra code anywhere else in the class.

Recomputing an expensive value on every read without measuring the cost first

Wrong

python
class Dataset:
    def __init__(self, rows):
        self.rows = rows

    @property
    def average(self):
        return sum(self.rows) / len(self.rows)   # O(n) on every single read

d = Dataset(list(range(10_000_000)))
for _ in range(1000):
    print(d.average)   # recomputes all 10 million rows, 1000 times

Better

python
class Dataset:
    def __init__(self, rows):
        self.rows = rows
        self._average = None

    @property
    def average(self):
        if self._average is None:
            self._average = sum(self.rows) / len(self.rows)
        return self._average

    @rows.setter
    def rows(self, value):
        self._rows = value
        self._average = None   # invalidate the cache on real changes

What you see: A loop reading d.average repeatedly becomes far slower than expected, because each read redoes an O(n) sum over the full dataset.

Why: Computed properties trade correctness-by-construction for repeated cost — that trade is right by default for cheap computations (string formatting, small arithmetic), but wrong once a property does real work on large data read in a hot loop. Caching is the fix, but it reintroduces exactly the staleness risk a computed property was chosen to avoid, so the cache must be explicitly invalidated wherever the underlying data can change.

A computed property never stores its own answer

first_name, last_name

the real, stored state

full_name property

recomputes from them on every read

always in sync

nothing separate to go stale

  1. first_name, last_name — the real, stored state
  2. full_name property — recomputes from them on every read
  3. always in sync — nothing separate to go stale

Stored attribute vs. computed property, for the same information

Stored attribute vs. computed property, for the same information
ApproachWhat can go wrong
self.full_name = f"{first} {last}" (stored once)stale if first_name/last_name change afterward
@property def full_name(self): (computed)always correct, but recomputed on every read
Cached computed property (lazy, reset on write)correct AND cheap, but adds real bookkeeping
Recomputing something expensive on every readcorrect but potentially slow — measure before caching

Together

python
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

p = Person("Ada", "Lovelace")
print(p.full_name)
p.first_name = "Grace"
print(p.full_name)   # stays correct automatically

Remember: A computed property derives its value on every read, so it can never go stale — only the cost of recomputing is the trade-off.

See also: property decorator · property deleters · encapsulation

Advertisement