In some languages you write getPrice() and setPrice(value) for every field that needs validation or computation. Python doesn't make you do that. You start with a plain attribute, and if it later needs validation or a computed value, you swap it for a property without changing any of the code that reads or writes it. This lesson covers @property, writable and deletable properties, computed and read-only fields, the small cost of going through a property, and where functools.cached_property fits in.
Most languages teach you to wrap every field in a pair of methods because exposing the field directly locks you in. Once outside code writes product.price = 30, you can't later say "wait, I need to reject negative prices" without breaking every caller. So you start with getPrice() and setPrice(value) from day one, just in case.
Python solves this differently. You can ship a class with a plain attribute today, and tomorrow turn that attribute into a method-backed property. Every line of caller code keeps working unchanged, because the syntax for reading and writing a property is the same as the syntax for reading and writing a plain attribute.
Start with the most boring version of a product:
Both reads and the write go straight to the attribute. There's no method call, no validation, nothing in between. Now suppose three weeks later someone files a bug: a customer was charged -50.00 for a mouse because an admin tool wrote a negative number into price. You need to reject negative prices.
In a language that forces getters and setters, you'd already have setPrice(value) everywhere and you'd just add the check inside. In Python, you have product.price = -50 scattered across the codebase. Rewriting every assignment to call a new method would be painful. So Python lets you keep the attribute syntax and add the validation behind it:
The caller's code from the first snippet still works without a single edit. The difference is invisible from outside: mouse.price looks like an attribute, behaves like an attribute, but actually runs a method on every read and write. This is the payoff. You don't pay the getter/setter tax up front. You add a property only when you actually need one.
The convention "private name with underscore + public property" is how Python keeps the real value (self._price) separate from the public interface (self.price). Without that split, the setter would call itself recursively, which we'll see in a moment.
@property: Make a Method Look Like an Attribute@property is a decorator that turns a method into something callers access without parentheses. The method becomes a read-only computed attribute. When someone writes cart.total, Python actually runs the total method behind the scenes and returns its result.
Start with a cart that computes its total from its items:
Notice the call site: cart.total, not cart.total(). The decorator handled that. Inside the class, total is still a regular method that takes self. Outside, callers see it as an attribute. That asymmetry is exactly what we want; the implementation can be a method, while the interface is an attribute.
What @property actually does is replace the function total on the class with a special property object that knows how to behave when you read or write cart.total. When you say cart.total, Python doesn't find a normal attribute on cart; it finds the property object on the class, and the property object calls the function for you.
Here's the access flow in pictures:
The diagram shows the fork. For a plain attribute (green), Python just reads or writes the instance dictionary. For a property (teal), Python routes through the property object, which calls the method you wrote. Same cart.price syntax at the call site, two very different paths underneath.
Because total here has only a getter, trying to assign to it fails:
That's read-only behavior, and we'll come back to it deliberately later in the lesson. For now, the takeaway is: @property alone gives you a computed attribute that callers read with no parentheses.
@<name>.setter: Make a Property WritableA property with only @property is read-only. To allow assignment, you pair the getter with a setter, decorated as @<name>.setter. The name has to match the property name, and the setter takes self and the value being assigned.
This is the place to put validation. The setter runs on every write, so any rule you want to enforce ("price must be non-negative", "stock can't be a float", "email needs an @ sign") goes here:
Three things to notice:
__init__ writes self.price = price, that assignment goes through the setter. So the validation kicks in for the constructor, not just for later edits. This is what you want: no way for a Product to exist with a negative price.round(value, 2), not value directly. So 24.999 became 24.99. The caller's view never sees the rounding, only the cleaned value.self._price, and the property price is the public face. If you tried to store it in self.price from inside the setter, you'd recurse forever, because self.price = ... would call the setter again. The leading underscore is the standard convention for "this is the storage, not the API".Let's see what happens if you forget the underscore:
What's wrong with this code?
self.price = value inside the setter is the same as any other self.price = .... It triggers the setter again, which assigns to self.price, which triggers the setter again, and so on. The fix is to store the value under a different name (self._price) and have the getter return that same private name.
Fix:
The setter is also the place where you'd raise ValueError for bad inputs. Bad type? Raise TypeError. Bad value but right type? Raise ValueError. Following Python's convention here makes your class feel like the standard library.
@<name>.deleter: The Rare Delete CaseYou can also tell a property what to do when someone writes del product.price. The decorator is @<name>.deleter, and the method takes only self. This is the least-used of the three, but it shows up when "deleting" needs to mean something specific, like resetting to a default or clearing a cached value.
del mouse.discount doesn't remove the attribute or the property. It just calls the deleter, which in this case resets the underlying storage to 0.0. The property itself stays in place; you can read and write mouse.discount again right after.
Most of the time you don't need a deleter. The two common reasons to add one are: clearing a cached or derived value so it gets recomputed next time, and resetting a piece of state to a default in a way that's more readable than obj.field = DEFAULT. If neither of those matches your case, skip the deleter.
The Cart.total we saw earlier is a computed property: there's no self._total stored anywhere. The value is calculated fresh from other attributes each time you read it. This is one of the most useful uses of @property, because it keeps related values automatically in sync without any code on your side.
Here's a slightly fuller cart that uses two computed properties:
Nobody told item_count or total to refresh. They didn't have to. Every read recomputes from the current list of items. There's no update_total() to call, no risk of a stale total that doesn't match the items list. The trade-off is that each read does the work again, which matters once the computation gets heavy. We'll see how to cache the result in a moment.
Computed properties pair well with classes whose internal state changes over time:
Three properties, each derived from self.items. total even depends on another property (subtotal and shipping). You can build derived values on top of derived values, and each one always reflects the current items list.
Leave off the @<name>.setter and you get a read-only property. Callers can read it, but any attempt to assign raises AttributeError. This is the right tool for fields that should be set once at construction time and never change afterward, like a customer's ID:
name is a plain attribute and accepts reassignment. customer_id is a read-only property and refuses. The _customer_id storage is technically still reachable as alice._customer_id (Python doesn't enforce true privacy), but the leading underscore is a strong "do not touch" signal, and going through the property is the only intended way.
Read-only properties also work well with computed values you want to expose but never accept writes for:
This is exactly the cart from earlier. Because there's no setter, cart.total = 0 fails. That's the right behavior: a total isn't something you set, it's something derived from the items. Refusing the assignment catches a whole class of bugs where someone tries to "fix" the total instead of fixing the items.
Cost: A property read calls a function under the hood. It's slightly slower than a plain attribute read, maybe a few hundred nanoseconds versus tens of nanoseconds. Imperceptible in normal code. Measurable only inside tight numeric loops that read the same property millions of times. If a benchmark says you have a problem, cache the value in a local variable before the loop instead of reading the property each iteration.
functools.cached_property: When the Computation Is ExpensiveA computed property is great until the computation is expensive and the inputs don't change. Reading cart.total 50 times in a render loop runs the sum 50 times, even when no items have been added between reads. For a small cart that's fine. For something that scans 10,000 items or hits a database, it isn't.
functools.cached_property solves this for cases where the value is computed once and then doesn't need to change. It's a decorator from the standard library that works almost exactly like @property, except the first access stores the result on the instance, and every later access reads that stored value directly. No more recomputation.
"computing total..." prints once, not three times. The first read runs the method, stores the result as order.total on the instance, and returns it. From then on, attribute lookup finds the stored value directly without calling the method.
The catch is exactly what makes it fast: the cached value doesn't update when the inputs change. If you mutate order.items after reading order.total once, the cached total stays at the old value. You'd have to delete the cached attribute (del order.total) to force a recomputation. So cached_property fits values that are effectively immutable for the lifetime of the object, or where you control invalidation yourself. For values that can change at any time, a regular @property is safer because it always reflects the current state.
cached_property has been in the standard library since Python 3.8. Before that, the common workaround was a manual cache attribute inside a regular @property. The standard library version is cleaner and handles the lookup correctly without you writing the cache logic each time.
Here's a small e-commerce snippet that pulls every piece of this lesson together: validation in a setter, a computed property, a read-only property, and a deleter that resets to a default.
Every property pulls its weight. Product.price validates type and value on every write, including the one inside __init__. Cart.total is a fresh computation each time, so adding items to the cart is immediately reflected in the total. Customer.customer_id is read-only; you can't accidentally renumber a customer. Customer.discount validates the range on writes and resets to zero on del. The caller code reads all of these the same way: with a dot and no parentheses.
10 quizzes