Skip to content

Property Decorator

What Are Properties?

1. Definition

A property allows you to define methods that behave like attributes. This supports encapsulation while enabling attribute-style access.

It is declared using the @property decorator and optionally @<property>.setter and @<property>.deleter.

2. Core Motivation

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 clean interface

3. Basic Syntax

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

    @property
    def area(self):
        from math import pi
        return pi * self._radius ** 2

Read-Only Properties

1. Simple Example

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  # Error: no setter defined

2. Why Use Read-Only

  • Prevents accidental modification of computed values
  • Encapsulates calculation logic
  • Maintains data consistency
  • Provides clean API without exposing implementation

3. Common Use Cases

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)

Alternative Approaches

1. Without Property

You'd have to do this instead:

class Person:
    def __init__(self, name):
        self.set_name(name)

    def get_name(self):
        return self._name

    def set_name(self, value):
        if not value.isalpha():
            raise ValueError("Name must be alphabetic")
        self._name = value

Accessing looks ugly:

p = Person("Alice")
p.set_name("Bob")
print(p.get_name())

2. With Property

Using @property, you retain encapsulation while exposing a clean interface:

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

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

3. API Comparison

Approach Read Syntax Write Syntax Pythonic
Methods p.get_name() p.set_name("Bob")
Property p.name p.name = "Bob"

Internal Mechanism

1. Descriptor Object

When you define a property:

@property
def name(self): ...

Python creates a descriptor object of type property, which:

  • Implements the __get__, __set__, and __delete__ methods
  • Lives in the class's namespace (Person.__dict__)
  • Manages how the attribute behaves at the instance level

2. Inspection

You can inspect it:

print(type(Person.name))  # <class 'property'>

3. How It Works

Properties are descriptors stored at the class level that intercept attribute access at the instance level.


Runnable Example: property_decorator_basic.py

"""
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 - The Property Checklist ============
    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.
    """)