Properties as a Unified Abstraction¶
Mental Model
A property is not three separate features. It is one concept --- a gatekeeper object that sits on a class and intercepts attribute access through up to three hooks: get, set, and delete. The caller writes obj.x and has no idea whether they are reading a stored value or triggering a method. Start with a plain attribute; add a property later when you need validation, computation, or access control --- without changing the public API.
One Descriptor, Three Hooks
Each @property creates a single descriptor object with up to three callables (fget, fset, fdel). The @prop.setter and @prop.deleter decorators attach additional hooks to the same descriptor. They are not independent features.
What Are Properties?¶
A property lets you define methods that behave like attributes. It is declared using the @property decorator and optionally @<name>.setter and @<name>.deleter.
Use properties to:
- Expose computed values as attributes
- Add getter/setter logic without changing the external API
- Enforce validation or read-only access
- Keep internal representation private while providing a clean interface
```python class Circle: def init(self, radius): self._radius = radius
@property
def area(self):
from math import pi
return pi * self._radius ** 2
c = Circle(3) print(c.area) # attribute-like access, but computed
c.area = 50 # AttributeError: no setter defined¶
```
Core Rule
@property = controlled attribute access. It lets you compute, validate, or restrict without changing how callers access the attribute. The caller writes obj.attr --- they never know whether it is a stored value or a method call.
Why Properties Exist¶
Start with a plain attribute:
```python class Circle: def init(self, radius): self.radius = radius
c = Circle(5) print(c.radius) # 5 c.radius = 10 # direct access ```
Later, you need validation. You have two choices.
Option 1: Introduce a method --- breaks existing code:
```python
Old code: c.radius = 10 <-- breaks¶
New code: c.set_radius(10) <-- different API¶
```
Every caller that wrote c.radius must be rewritten.
Option 2: Use @property --- old code still works:
```python class Circle: def init(self, radius): self.radius = radius # triggers setter
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("must be positive")
self._radius = value
c = Circle(5) c.radius = 10 # still works -- setter validates silently print(c.radius) # still works -- getter returns value ```
Callers see no change. Validation was added without touching the interface.
The Design Principle
@property separates what users see (obj.radius) from how it works (validation, computation, storage). This lets you evolve implementation without breaking existing code --- the same principle Java solves with boilerplate getters/setters, but without the boilerplate.
| Approach | Read Syntax | Write Syntax | Pythonic |
|---|---|---|---|
| Explicit methods | p.get_name() |
p.set_name("Bob") |
No |
| Property | p.name |
p.name = "Bob" |
Yes |
The Three Hooks: Getter, Setter, Deleter¶
Getter¶
The @property decorator turns a method into a readable attribute.
```python class Circle: def init(self, radius): self._radius = radius
@property
def radius(self):
return self._radius
```
Setter¶
The @<name>.setter decorator defines what happens on assignment.
python
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Negative radius")
self._radius = value
Deleter¶
The @<name>.deleter decorator defines what happens on del obj.attr.
python
@radius.deleter
def radius(self):
del self._radius
Most properties do not need a deleter. Common uses include invalidating caches, closing resources, and resetting state.
Name Matching Requirement
The setter and deleter method names must be identical to the property name --- @radius.setter def radius(self, value). A mismatched name silently creates a separate property instead of attaching a hook to the existing one.
Read-Only Properties¶
A read-only property is a @property with no setter. Any attempt to assign to it raises AttributeError.
```python class Circle: def init(self, radius): self._radius = radius
@property
def area(self):
from math import pi
return pi * self._radius ** 2
c = Circle(5) print(c.area) # 78.54...
c.area = 100 # AttributeError: can't set attribute¶
```
Use read-only properties when:
- The value is derived from other state (area from radius)
- External modification would break invariants
- You want to express immutability at the language level
Immutable Objects¶
Making all public attributes read-only via properties creates an immutable interface --- external code cannot reassign obj.x. However, this is not true immutability: obj._x = 100 still works because the private attribute is just a naming convention.
```python class Point: def init(self, x, y): self._x = x self._y = y
@property
def x(self):
return self._x
@property
def y(self):
return self._y
@property
def magnitude(self):
return (self._x ** 2 + self._y ** 2) ** 0.5
def move(self, dx, dy):
"""Return a new Point -- the original is unchanged."""
return Point(self._x + dx, self._y + dy)
```
```python p = Point(3, 4)
p.x = 10 # AttributeError — property prevents this¶
p._x = 10 # still works — properties prevent assignment, not mutation ```
| Level | What properties provide |
|---|---|
| API immutability | Yes --- obj.x = val raises AttributeError |
| Internal immutability | No --- obj._x = val still works |
| True immutability | Use @dataclass(frozen=True) or override __setattr__ |
Benefits of the property approach: easier to reason about, clear API intent, usable as dictionary keys (if you also define __hash__). For true enforcement, use from dataclasses import dataclass with frozen=True.
Caveat: __dict__ Override¶
Writing directly to obj.__dict__['area'] = 100 bypasses the descriptor. The shadowed value is never returned because data descriptors take priority, but it can cause confusion. Avoid manipulating __dict__ directly for property-controlled attributes. For more, see Properties as Descriptors.
Internal Mechanism¶
The Plain-English Version¶
When you write @property above a method, Python creates a gatekeeper object that sits on the class and watches for anyone trying to read, write, or delete that attribute name.
- Read
obj.name--- the gatekeeper calls your getter and returns the result. - Write
obj.name = value--- the gatekeeper calls your setter (or raises an error if none exists). - Delete
del obj.name--- the gatekeeper calls your deleter.
The gatekeeper always runs before Python checks the instance's own dictionary, so it cannot be bypassed by accident. This is why properties can enforce validation --- the gatekeeper intercepts every access.
The Technical Details¶
@property creates a descriptor object of type property that implements __get__, __set__, and __delete__. It lives in the class's namespace (Circle.__dict__). Because it defines both __get__ and __set__, it is a data descriptor --- it takes priority over instance __dict__ entries during attribute lookup.
python
print(type(Circle.area)) # <class 'property'>
print(isinstance(Circle.area, property)) # True
Every property object stores three function references internally:
fget--- the getter function. Called when you read the attribute (obj.x). This is the method you decorate with@property.fset--- the setter function. Called when you assign to the attribute (obj.x = value). This is the method you decorate with@x.setter. IfNone, assignment raisesAttributeError.fdel--- the deleter function. Called when you delete the attribute (del obj.x). This is the method you decorate with@x.deleter. IfNone, deletion raisesAttributeError.
You can inspect them directly:
python
Circle.area.fget # <function Circle.area at 0x...>
Circle.area.fset # None (read-only — no setter defined)
Circle.area.fdel # None (no deleter defined)
These are two layers of the same mechanism — just like a + b is actually a.__add__(b), attribute access is actually a descriptor call:
| Syntax | Python desugars to | Which calls |
|---|---|---|
obj.x (read) |
type(obj).__dict__['x'].__get__(obj, type(obj)) |
fget(obj) |
obj.x = v (write) |
type(obj).__dict__['x'].__set__(obj, v) |
fset(obj, v) |
del obj.x (delete) |
type(obj).__dict__['x'].__delete__(obj) |
fdel(obj) |
And the mapping from your decorators to what gets stored:
| Your code | Stored in property as | Descriptor method that calls it |
|---|---|---|
@property def x |
fget |
__get__ |
@x.setter def x |
fset |
__set__ |
@x.deleter def x |
fdel |
__delete__ |
fget/fset/fdel = what to do (your functions). __get__/__set__/__delete__ = when Python does it (descriptor protocol, triggered by attribute access). The property object connects the two: property.__get__(obj, cls) internally calls fget(obj).
Why Properties Enforce Control
Properties work because they are data descriptors --- they define both __get__ and __set__ (even if the setter raises AttributeError). Data descriptors are checked before the instance __dict__ during attribute lookup. Without this mechanism, any assignment would bypass property logic entirely.
For the full descriptor mechanics, see Properties as Descriptors.
Common Patterns¶
Computed Values¶
```python class Rectangle: def init(self, width, height): self.width = width self.height = height
@property
def area(self):
return self.width * self.height
@property
def perimeter(self):
return 2 * (self.width + self.height)
@property
def diagonal(self):
return (self.width ** 2 + self.height ** 2) ** 0.5
```
Derived Attributes¶
```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}"
@property
def initials(self):
return f"{self.first_name[0]}.{self.last_name[0]}."
```
Lazy Evaluation¶
Defer expensive work until the value is actually needed. This is a manual version of a cached property. For a reusable descriptor-based implementation, see Cached Properties.
```python class ExpensiveCalculation: def init(self, data): self._data = data self._result = None
@property
def result(self):
if self._result is None:
self._result = sum(x ** 2 for x in self._data)
return self._result
```
Conditional Read-Only¶
Make a property writable until some condition locks it:
```python class Document: def init(self): self._content = "" self._locked = False
@property
def content(self):
return self._content
@content.setter
def content(self, value):
if self._locked:
raise AttributeError("Document is locked")
self._content = value
def lock(self):
self._locked = True
```
Type Conversion¶
```python class DataRecord: def init(self, timestamp_str): self._timestamp_str = timestamp_str
@property
def timestamp(self):
"""Always returns a datetime object."""
from datetime import datetime
return datetime.fromisoformat(self._timestamp_str)
```
Properties vs Methods¶
Use @property when the access looks like reading an attribute: the name is a noun, the computation is fast, and there are no side effects beyond the computation itself.
Use a method when the operation is a verb, is expensive, has side effects, takes parameters, or might involve I/O.
| Criterion | Property | Method |
|---|---|---|
| Name | Noun (area, name) |
Verb (save, fetch) |
| Speed | Fast / cached | May be slow |
| Side effects | None | Allowed |
| Parameters | None (beyond self) |
Accepted |
| Example | circle.area |
db.query(sql) |
```python
Property -- noun, fast, pure computation¶
class Circle: @property def area(self): import math return math.pi * self.radius ** 2
Method -- verb, expensive, I/O¶
class Database: def query(self, sql): return self.execute(sql) ```
Best Practices¶
- Start with plain attributes. Add
@propertyonly when you need validation, computation, or read-only access. - Keep properties fast. Users expect
obj.xandobj.x = valto be near-instant. Use methods for expensive operations. - Prefer immutability. Omit the setter to make an attribute read-only when external modification would break invariants.
- Don't write trivial properties. A getter that returns
self._xwith no validation or computation adds noise, not value. Use a plain attribute instead. - Document your properties. The getter's docstring becomes the property's
__doc__. - Use lazy loading for expensive computations. Cache the result in a private attribute so work is done at most once.
Runnable Example: property_decorator_basic.py¶
```python """ TUTORIAL: The @property Decorator - Computed Attributes
This tutorial teaches you how to use the @property decorator to create computed attributes that look like simple instance attributes but actually run custom code. Properties let you write foo.bar instead of foo.get_bar() while still controlling access, validation, and computation.
This is one of Python's most useful and commonly used features.
Key Learning Goals: - Understand why properties are useful - Implement basic properties with getters and setters - Learn property documentation and introspection - See how properties enforce encapsulation without boilerplate - Understand performance implications """
if name == "main":
print("=" * 70)
print("TUTORIAL: The @property Decorator - Computed Attributes")
print("=" * 70)
# ============ EXAMPLE 1: The Problem - Boilerplate Without Properties ============
print("\n# Example 1: Without Properties - The Verbose Way")
print("=" * 70)
class BankAccountBad:
"""
Bank account WITHOUT properties (the verbose, non-Pythonic way).
This is how you might write code in Java or C#. Not in Python!
"""
def __init__(self, name, balance):
self._name = name
self._balance = balance
def get_name(self):
"""Get the account holder name."""
return self._name
def set_name(self, value):
"""Set the account holder name."""
if not isinstance(value, str) or not value.strip():
raise ValueError("Name must be non-empty string")
self._name = value
def get_balance(self):
"""Get the current balance."""
return self._balance
def set_balance(self, value):
"""Set the balance (with validation)."""
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
print("Without properties, you write:")
account = BankAccountBad("Alice", 1000)
print(f"Name: {account.get_name()}")
print(f"Balance: {account.get_balance()}")
print()
print("This is verbose and repetitive!")
print("Users have to remember: get_name(), get_balance(), etc.")
print("And we lost the nice attribute syntax: account.name")
print("""
WHY THIS IS BAD:
- Too much boilerplate (get_/set_ methods)
- Ugly syntax: account.get_name() vs account.name
- Hard to add validation later (breaks API)
- Not Pythonic (Python prefers simple attribute access)
""")
# ============ EXAMPLE 2: Basic Property - Read-Only ============
print("\n# Example 2: Basic Property - Read-Only Access")
print("=" * 70)
class Person:
"""
Person class with a read-only name property.
The @property decorator turns a method into a readable attribute.
"""
def __init__(self, name):
self._name = name # Private attribute (convention: leading underscore)
@property
def name(self):
"""
The name property.
After @property decorator, you access this as person.name
(attribute syntax) not person.name() (method syntax).
The method becomes a property getter.
"""
print(f" [Getting name: {self._name}]") # Show that code runs
return self._name
person = Person("Bob")
print(f"Accessing person.name (attribute syntax, not method):")
name = person.name
print(f"Got: {name}")
print()
print("Try to set it:")
try:
person.name = "Charlie"
except AttributeError as e:
print(f" Error: {e}")
print("""
WHY: The @property decorator makes the method look like an attribute.
You write person.name not person.name(). Clean and intuitive!
Since we only have @property (getter), the attribute is read-only.
Attempting to set it raises AttributeError.
""")
# ============ EXAMPLE 3: Property with Setter ============
print("\n# Example 3: Property with Getter and Setter")
print("=" * 70)
class Rectangle:
"""
Rectangle with width and height properties that allow getting and setting.
Use @property for the getter, @name.setter for the setter.
Both run custom code (like validation).
"""
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
"""Get the width."""
return self._width
@width.setter
def width(self, value):
"""
Set the width with validation.
The setter method name must match the property name.
The decorator is @name.setter, not @property.
"""
if not isinstance(value, (int, float)):
raise TypeError("Width must be a number")
if value <= 0:
raise ValueError("Width must be positive")
print(f" [Setting width to {value}]")
self._width = value
@property
def height(self):
"""Get the height."""
return self._height
@height.setter
def height(self, value):
"""Set the height with validation."""
if not isinstance(value, (int, float)):
raise TypeError("Height must be a number")
if value <= 0:
raise ValueError("Height must be positive")
print(f" [Setting height to {value}]")
self._height = value
@property
def area(self):
"""
Computed property: area is calculated, not stored.
This is read-only (no setter) - it's always computed from width/height.
"""
return self._width * self._height
rect = Rectangle(3, 4)
print(f"Width: {rect.width}, Height: {rect.height}")
print(f"Area: {rect.area} (computed, not stored)")
print()
print("Setting width to 5:")
rect.width = 5
print(f"New area: {rect.area}")
print()
print("Validation works:")
try:
rect.width = -10
except ValueError as e:
print(f" Error: {e}")
try:
rect.height = "invalid"
except TypeError as e:
print(f" Error: {e}")
print("""
WHY: Properties let you:
1. Use attribute syntax (rect.width not rect.get_width())
2. Run validation code transparently
3. Compute values on-the-fly (rect.area)
4. Control access (read-only, write-only, or both)
5. Change implementation without breaking the API
If you later want to add validation or computation, users don't know
or care - they still use attribute syntax.
""")
# ============ EXAMPLE 4: Property Documentation ============
print("\n# Example 4: Property Documentation and Introspection")
print("=" * 70)
class Foo:
"""Example class with documented properties."""
@property
def bar(self):
"""
The bar attribute.
This docstring becomes the property's documentation.
Visible via help() and IDE tooltips.
"""
return self.__dict__.get('bar', 'default')
@bar.setter
def bar(self, value):
self.__dict__['bar'] = value
# Access property documentation
print("Property documentation:")
print(f" Foo.bar.__doc__ = {repr(Foo.bar.__doc__)}")
print()
foo = foo_instance = Foo()
# Inspect properties
print("Introspection:")
print(f" type(Foo.bar) = {type(Foo.bar)}")
print(f" isinstance(Foo.bar, property) = {isinstance(Foo.bar, property)}")
print()
# Help on property
print("Help for property:")
print(f" Foo.bar.fget = {Foo.bar.fget}") # getter function
print(f" Foo.bar.fset = {Foo.bar.fset}") # setter function
print(f" Foo.bar.fdel = {Foo.bar.fdel}") # deleter function (None if not defined)
print("""
WHY: Properties have introspectable metadata:
- __doc__: The docstring
- fget, fset, fdel: The getter, setter, deleter functions
- You can check isinstance(attr, property) to detect properties
This is useful for frameworks and tools that need to understand
your class structure.
""")
# ============ EXAMPLE 5: Using __dict__ Directly (Storage Pattern) ============
print("\n# Example 5: Storing in __dict__ (Instance Dictionary)")
print("=" * 70)
class Thermostat:
"""
Thermostat with temperature property that stores in __dict__.
The common pattern: store attributes in self.__dict__ instead of
private attributes like self._temp.
"""
@property
def celsius(self):
"""
Get temperature in Celsius.
This gets/sets directly in the __dict__ dictionary.
"""
return self.__dict__.get('celsius', 20) # default 20 degrees
@celsius.setter
def celsius(self, value):
"""Set temperature with bounds checking."""
if not -50 <= value <= 50:
raise ValueError("Temperature must be between -50 and 50 Celsius")
self.__dict__['celsius'] = value
@property
def fahrenheit(self):
"""Get temperature in Fahrenheit (computed)."""
return (self.celsius * 9/5) + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""Set temperature from Fahrenheit."""
self.celsius = (value - 32) * 5/9
thermo = Thermostat()
print(f"Default temperature: {thermo.celsius}C = {thermo.fahrenheit}F")
print()
print("Setting to 25C:")
thermo.celsius = 25
print(f" {thermo.celsius}C = {thermo.fahrenheit}F")
print()
print("Setting to 68F:")
thermo.fahrenheit = 68
print(f" {thermo.celsius:.1f}C = {thermo.fahrenheit:.1f}F")
print()
print("Instance __dict__:")
print(f" {thermo.__dict__}")
print("""
WHY: Using __dict__ directly is clean and idiomatic Python.
You don't need separate private attributes when properties manage
access to __dict__.
PATTERN:
@property
def attr(self):
return self.__dict__.get('attr', default)
@attr.setter
def attr(self, value):
self.__dict__['attr'] = value
""")
# ============ EXAMPLE 6: Common Anti-Pattern - Avoid This ============
print("\n# Example 6: Anti-Pattern - Storing in Private Attribute")
print("=" * 70)
class BadDesign:
"""Example of property anti-pattern (stores in _attr for no reason)."""
def __init__(self):
self._value = None
@property
def value(self):
# Unnecessary indirection!
return self._value
@value.setter
def value(self, x):
self._value = x # Just copying to self._value. Why?
print("This is an anti-pattern - the property adds no value:")
print(" - No validation")
print(" - No computation")
print(" - Just stores in _value")
print()
print("""
BETTER: Just use self.value = x directly
If you later need validation:
@property
def value(self):
return self.__dict__.get('value')
@value.setter
def value(self, x):
if not validate(x):
raise ValueError("Invalid")
self.__dict__['value'] = x
Only use @property when you need custom behavior (validation,
computation, access control). Don't use it for trivial getters/setters.
""")
# ============ EXAMPLE 7: Deleter and Other Tricks ============
print("\n# Example 7: Property Deleter - Handling Deletion")
print("=" * 70)
class Cache:
"""
Cache with a value property that can be deleted.
@name.deleter defines what happens when you delete the property.
"""
def __init__(self):
self._value = None
self._cached = False
@property
def value(self):
"""Get the cached value."""
return self._value
@value.setter
def value(self, x):
"""Set and mark as cached."""
self._value = x
self._cached = True
@value.deleter
def value(self):
"""Delete and invalidate cache."""
print(" [Cache invalidated]")
self._value = None
self._cached = False
cache = Cache()
print("Setting cache value:")
cache.value = "important data"
print(f" Value: {cache.value}, Cached: {cache._cached}")
print()
print("Deleting cache:")
del cache.value
print(f" Value: {cache.value}, Cached: {cache._cached}")
print("""
WHY: The deleter lets you handle del obj.property.
COMMON USES:
- Invalidate caches
- Close resources
- Reset state
- Trigger cleanup
Most properties don't need deleters, but they're there when needed.
""")
# ============ EXAMPLE 8: Lazy Evaluation with Properties ============
print("\n# Example 8: Lazy Evaluation - Compute Only When Needed")
print("=" * 70)
class ExpensiveComputation:
"""
Properties for lazy evaluation: compute only when accessed.
This pattern defers expensive work until the value is actually needed.
"""
def __init__(self, name):
self.name = name
self._result = None # None means "not computed yet"
@property
def result(self):
"""
Compute expensive result, but only on first access.
Subsequent accesses return the cached result.
"""
if self._result is None:
print(f" [Computing result for {self.name}...]")
import time
time.sleep(0.1) # Simulate expensive work
self._result = f"Result of {self.name}"
else:
print(f" [Using cached result for {self.name}]")
return self._result
obj = ExpensiveComputation("task")
print("First access (computes):")
r1 = obj.result
print(f" Got: {r1}")
print()
print("Second access (cached):")
r2 = obj.result
print(f" Got: {r2}")
print("""
WHY: Lazy evaluation defers expensive computations:
- If the result is never accessed, work isn't done
- If accessed multiple times, work is done once
- Client code uses simple property access
PATTERN:
@property
def expensive_attr(self):
if self._cached_value is None:
self._cached_value = do_expensive_work()
return self._cached_value
""")
# ============ EXAMPLE 9: Properties vs Methods ============
print("\n# Example 9: Choosing Between Properties and Methods")
print("=" * 70)
print("""
USE @property WHEN:
- Value looks like an attribute (singular noun)
- Access is fast (cached or simple computation)
- Logically feels like an attribute (name, age, area)
- No side effects beyond computation
- You might want to change to simple attribute later
USE A METHOD WHEN:
- Operation name is a verb (get_user(), save())
- Computation is expensive (database query)
- Has side effects (I/O, modifies state)
- Takes parameters (get(key), find(pattern))
- Might take variable time (network request)
EXAMPLES:
PROPERTY:
class Circle:
@property
def area(self): # Noun, fast, pure computation
return math.pi * self.radius ** 2
METHOD:
class Database:
def query(self, sql): # Verb, expensive, I/O
return self.execute(sql)
class User:
def save(self): # Verb, side effects
self.db.insert(self)
PROPERTY:
class Point:
@property
def magnitude(self): # Noun, fast, pure
return math.hypot(self.x, self.y)
METHOD:
class API:
def fetch(self, url): # Verb, expensive, I/O
return requests.get(url)
""")
# ============ EXAMPLE 10: Summary ============
print("\n# Example 10: Property Implementation Checklist")
print("=" * 70)
class CompleteExample:
"""Example showing all property features together."""
def __init__(self, name):
self._name = name
self._active = True
@property
def name(self):
"""
Get the name.
Always include documentation for properties!
"""
return self._name
@name.setter
def name(self, value):
"""Set name with validation."""
if not isinstance(value, str) or not value.strip():
raise ValueError("Name must be non-empty string")
self._name = value
@name.deleter
def name(self):
"""Deleting name disables the object."""
self._active = False
@property
def is_active(self):
"""Read-only property (no setter)."""
return self._active
print("Complete example:")
obj = CompleteExample("Alice")
print(f"Name: {obj.name}")
print(f"Active: {obj.is_active}")
print()
obj.name = "Bob"
print(f"After setting name: {obj.name}")
print()
del obj.name
print(f"After deleting name: {obj.is_active}")
print("""
PROPERTY CHECKLIST:
- Use @property for getter
- Use @name.setter for setter (if needed)
- Use @name.deleter for deleter (rarely needed)
- Store in __dict__ or private attribute
- Validate in setter
- Include docstring
- Handle None defaults gracefully
- Make computation fast or lazy-load
- Don't use for expensive I/O (use methods instead)
- Document return type in docstring
PERFORMANCE:
- Properties have slightly more overhead than attributes
- But far less than method calls
- Use lazy-loading for expensive computations
- Cache results when appropriate
""")
print("\n" + "=" * 70)
print("KEY TAKEAWAYS")
print("=" * 70)
print("""
1. PROPERTIES LOOK LIKE ATTRIBUTES: @property makes methods look like
simple attributes. You write obj.name not obj.name().
2. POWERFUL ENCAPSULATION: Properties let you validate, compute, or
control access without breaking the API.
3. CHANGE IMPLEMENTATION WITHOUT BREAKING CODE: Start with simple
attributes. Add @property later if you need custom behavior.
4. FOLLOW NAMING CONVENTIONS: Name properties like nouns (name, area,
is_active) and methods like verbs (get_data(), save()).
5. USE __dict__ STORAGE: Store properties in self.__dict__ directly
for a clean, idiomatic pattern.
6. DOCUMENT YOUR PROPERTIES: Include docstrings explaining what the
property does, its type, and any constraints.
7. LAZY-LOAD EXPENSIVE COMPUTATIONS: Cache results to avoid recomputing
on every access.
8. DON'T OVERUSE PROPERTIES: Only use them when you need custom behavior.
Simple attributes are fine as-is.
9. PROPERTIES ARE PYTHONIC: They're one of Python's most elegant features.
Used everywhere in professional code.
10. COMBINATION WITH OTHER FEATURES: Properties work great with
descriptors, metaclasses, and other advanced features - but you
usually don't need them.
""")
```
Exercises¶
Exercise 1. Create a class BankAccount with a private _balance attribute. Use @property to provide read-only access to the balance. Add deposit(amount) and withdraw(amount) methods that modify _balance with validation (no negative deposits, no overdrafts). Show that assigning to account.balance raises an error.
Solution to Exercise 1
```python class BankAccount: def init(self, initial_balance=0): self._balance = initial_balance
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
account = BankAccount(1000) print(account.balance) # 1000 account.deposit(500) print(account.balance) # 1500 account.withdraw(200) print(account.balance) # 1300
try: account.balance = 9999 except AttributeError as e: print(e) # property 'balance' of 'BankAccount' object has no setter ```
Exercise 2. Write a Temperature class with a celsius property that has a getter, setter, and deleter. The setter should reject values below \(-273.15\). The deleter should reset the temperature to 0. Demonstrate all three operations.
Solution to Exercise 2
```python class Temperature: def init(self, celsius=0): 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
@celsius.deleter
def celsius(self):
self._celsius = 0
t = Temperature(100) print(t.celsius) # 100
t.celsius = -10 print(t.celsius) # -10
del t.celsius print(t.celsius) # 0
try: t.celsius = -300 except ValueError as e: print(e) # Below absolute zero ```
Exercise 3. Create an immutable Point class with read-only x and y properties and a read-only distance_from_origin property that computes \(\sqrt{x^2 + y^2}\). Show that attempting to set x or y raises an error.
Solution to Exercise 3
```python import math
class Point: def init(self, x, y): self._x = x self._y = y
@property
def x(self):
return self._x
@property
def y(self):
return self._y
@property
def distance_from_origin(self):
return math.sqrt(self._x ** 2 + self._y ** 2)
p = Point(3, 4) print(p.x) # 3 print(p.y) # 4 print(p.distance_from_origin) # 5.0
try: p.x = 10 except AttributeError: print("Cannot set x") # Cannot set x ```
Exercise 4. Implement a Document class whose content property starts as writable but becomes read-only after calling lock(). Demonstrate both the writable and locked states.
Solution to Exercise 4
```python class Document: def init(self, content=""): self._content = content self._locked = False
@property
def content(self):
return self._content
@content.setter
def content(self, value):
if self._locked:
raise AttributeError("Document is locked")
self._content = value
def lock(self):
self._locked = True
doc = Document() doc.content = "Hello, world!" print(doc.content) # Hello, world!
doc.lock() try: doc.content = "New text" except AttributeError as e: print(e) # Document is locked ```
Exercise 5. A Student class has name and an optional id_num. The tuition price should be 7000 if the student has an ID number, and 8000 otherwise. A junior developer stores price as a plain attribute set in __init__. Explain why a @property is a better design, then implement it. Show that the same class produces different prices for different students without any explicit assignment to price.
```python
Junior version (plain attribute)¶
class Student: def init(self, name, id_num=None): self.name = name self.id_num = id_num if id_num is not None: self.price = 7000 else: self.price = 8000 ```
Solution to Exercise 5
The plain-attribute version works, but the price logic is buried in __init__ and can be overridden by accident (a.price = 0). A @property makes the rule explicit, computed, and read-only:
```python class Student: def init(self, name, id_num=None): self.name = name self.id_num = id_num
@property
def price(self):
if self.id_num is not None:
return 7000
else:
return 8000
a = Student("Kim", 12345) print(a.price) # 7000
b = Student("Lee") print(b.price) # 8000
a.price = 0 # AttributeError -- cannot override the rule¶
```
The @property version is better because: (1) the pricing rule lives in one place, not scattered in __init__, (2) it is read-only --- external code cannot accidentally set price to an invalid value, and (3) if the rule changes later (e.g., different tiers), you modify one method instead of hunting through constructors.
Exercise 6. Write a Person class where name is a property with both a getter and setter. The setter should strip leading/trailing whitespace and raise ValueError if the name is empty after stripping. Add a read-only full_name property using first_name and last_name stored internally. Demonstrate both valid and invalid usage.
Solution to Exercise 6
```python class Person: def init(self, name): self.name = name # uses the setter
@property
def name(self):
return f"{self._first} {self._last}"
@name.setter
def name(self, value):
cleaned = value.strip()
if not cleaned:
raise ValueError("Name cannot be empty")
parts = cleaned.split(maxsplit=1)
self._first = parts[0]
self._last = parts[1] if len(parts) > 1 else ""
p = Person(" Alice Smith ") print(p.name) # Alice Smith
p.name = "Bob Jones" print(p.name) # Bob Jones
try: p.name = " " except ValueError as e: print(e) # Name cannot be empty ```