Intermediate Python 📂 Class and Object · 6 of 10 41 min read

Python OOP — Properties, Getter, Setter & Deleter Explained

Master Python's @property decorator — the clean way to expose attributes with validated reads, controlled writes, and custom cleanup on delete. Learn @property (getter), @x.setter, @x.deleter, read-only and computed properties through a bank-teller analogy, three animated SVG diagrams (property vs attribute, setter validation flow, full lifecycle), Temperature and BankAccount practical examples, and 10 golden rules.

Section 01

The Story That Explains Properties

The Bank Teller — Not the Vault, Not the Form, the Friendly Face
You walk into a bank and want to know your balance. Three doors face you:

Door 1 — walk into the vault yourself. You could grab any file, change any number. That's a public attribute — acc.balance = -999999 would work, no questions asked.

Door 2 — fill out request forms. Form A for "get balance," Form B for "deposit," Form C for "close account." Every read and write means calling acc.get_balance() or acc.set_balance(500). Safe, but ugly and verbose.

Door 3 — the friendly teller. You just say "what's my balance?" ( acc.balance). Behind the counter, the teller checks records, applies rules, logs the transaction, and returns the answer. You didn't fill out a form — but validation still happened. Change your balance? Say acc.balance = 5000, and the teller validates before writing.

The teller is Python's @property. From outside, it looks and behaves like a plain attribute. Inside, it runs a method — with getter for reads, setter for writes, and deleter for cleanup.
🔑
The Core Insight

A property is a special attribute whose read, write, and delete operations are secretly method calls. Callers write obj.balance — but under the hood, Python fires your @property for reads, your @balance.setter for writes, and your @balance.deleter for del obj.balance. Clean syntax on the outside, full control on the inside.


Section 02

The Problem Properties Solve

There are three ways to expose data from a class. Two of them have serious downsides. Properties are the third — and the reason they exist.

# ── Option 1: Public attribute — unsafe ────────────────────
class BankAccount:
    def __init__(self, balance):
        self.balance = balance

acc = BankAccount(1000)
acc.balance = -999999              # nothing stops this — the class contract is gone


# ── Option 2: Java-style getter/setter methods — ugly ──────
class BankAccount:
    def __init__(self, balance):
        self._balance = balance

    def get_balance(self):
        return self._balance

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

acc = BankAccount(1000)
print(acc.get_balance())              # 1000  ← works, but every read is a function call in the code
acc.set_balance(1500)                # works — but the class API is verbose


# ── Option 3: @property — safe AND clean ──────────────────
class BankAccount:
    def __init__(self, balance):
        self.balance = balance         # goes through the setter — automatic validation

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

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

acc = BankAccount(1000)
print(acc.balance)                   # 1000   ← looks like a plain attribute...
acc.balance = 1500                    # ...but the setter enforces rules under the hood
🏆
Why This Matters

With properties, you can start with a plain public attribute (self.balance = ...) and later upgrade it to a validated property — without breaking any code that already uses acc.balance. That's the quiet superpower: your class's external API stays the same while the internals gain rules.


Section 03

@property — The Getter

The @property decorator turns a method into a read-only attribute. Callers write obj.balance, but Python secretly calls the method. Nothing is stored as an attribute; the value comes from whatever the method returns.

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

    @property
    def radius(self):
        return self._radius

    @property
    def area(self):
        # Computed on the fly — no stored attribute
        return 3.14159 * self._radius ** 2

    @property
    def circumference(self):
        return 2 * 3.14159 * self._radius

c = Circle(5)
print(c.radius)         # 5           ← looks like an attribute
print(c.area)           # 78.53975    ← same syntax, but computed
print(c.circumference)  # 31.4159

# Trying to write to a property that has no setter
try:
    c.area = 100
except AttributeError as e:
    print("Blocked:", e)    # property 'area' of 'Circle' object has no setter
OUTPUT
5 78.53975 31.4159 Blocked: property 'area' of 'Circle' object has no setter

Animated Diagram — A Property Looks Like an Attribute, Behaves Like a Method

@PROPERTY  ·  ATTRIBUTE SYNTAX, METHOD BEHAVIOUR
OUTSIDE CODE c.area reads like an attribute class Circle — inside @property GETTER METHOD def area(self): return 3.14 * self._radius ** 2 runs every time you write c.area RESULT 78.53975

Every c.area lookup silently runs the getter and returns whatever it computes — no stored attribute, no ceremony.


Section 04

@x.setter — Validated Writes

A property with only @property is read-only. To allow writes, add a matching @name.setter method. This is where validation lives — the setter intercepts every assignment and can reject bad values before they touch the object.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age  = age                   # goes THROUGH the setter

    @property
    def age(self):                     # GETTER
        return self._age

    @age.setter
    def age(self, value):              # SETTER — same method name!
        if not isinstance(value, int):
            raise TypeError("age must be an int")
        if value < 0 or value > 150:
            raise ValueError("age must be 0..150")
        self._age = value

alice = Person("Alice", 30)
print(alice.age)               # 30   ← getter runs

alice.age = 31                 # setter runs, passes validation
print(alice.age)               # 31

# ── Bad values are rejected AT THE SETTER, before touching the object ──
try:
    alice.age = -5
except ValueError as e:
    print("Rejected:", e)      # age must be 0..150

try:
    alice.age = "forty"
except TypeError as e:
    print("Rejected:", e)      # age must be an int
OUTPUT
30 31 Rejected: age must be 0..150 Rejected: age must be an int

Animated Diagram — Setter Validation Flow

Watch two write attempts. alice.age = 31 passes validation and is stored. alice.age = -5 is caught by the setter and rejected — the underlying _age is never touched.

@X.SETTER  ·  VALIDATION BEFORE STORAGE
GOOD WRITE alice.age = 31 BAD WRITE alice.age = -5 @age.setter def age(self, value): if not isinstance(value, int): raise TypeError(...) if value < 0 or value > 150: raise ValueError(...) self._age = value every write goes through here STORED _age = 31 REJECTED ValueError Bad values never reach _age. The setter is your gate.

The setter is a filter. Good values pass through to storage. Bad values raise an exception and the object stays untouched.

🛠️
Use a Different Underlying Name

Never store the value in the property's own name (self.age inside the setter). That would recurse forever — self.age = value would call the setter again. The universal convention: property named x stores under _x. That's why the setter writes to self._age, not self.age.


Section 05

@x.deleter — Custom Cleanup on del obj.x

The third and least-used piece of the property trio. A deleter runs when a caller writes del obj.x. Use it when deleting the property should do more than just remove the attribute — resetting to a default, closing a connection, logging.

class Session:
    def __init__(self, token):
        self._token = token

    @property
    def token(self):
        return self._token

    @token.setter
    def token(self, value):
        if not isinstance(value, str) or len(value) < 8:
            raise ValueError("token must be a string of length ≥ 8")
        self._token = value

    @token.deleter
    def token(self):
        # Custom cleanup — logout, revoke, wipe
        print(f"Logging out session for token '{self._token[:4]}...'")
        self._token = None

s = Session("abc-secret-token")
print(s.token)         # abc-secret-token

del s.token           # deleter runs → "Logging out session for token 'abc-...'"
print(s.token)         # None
OUTPUT
abc-secret-token Logging out session for token 'abc-...' None

Section 06

Animated Diagram — The Full Property Lifecycle

One property, three operations, three separate methods behind the scenes. Read (x = obj.balance) → getter fires. Write (obj.balance = 100) → setter fires. Delete (del obj.balance) → deleter fires.

PROPERTY LIFECYCLE  ·  GET · SET · DELETE
READ x = acc.balance WRITE acc.balance = 100 DELETE del acc.balance @property  ·  GETTER def balance(self): return self._balance return the value @balance.setter  ·  SETTER def balance(self, v): if v < 0: raise ValueError self._balance = v @balance.deleter  ·  DELETER def balance(self): print("closing"); del self._balance

One property name, three decorated methods. Python routes each access — read, write, delete — to the correct one automatically.


Section 07

Special Cases — Read-Only and Computed Properties

Not every property needs all three of getter, setter, and deleter. Two very common patterns:

🔒
Read-Only Property
only @property, no setter
Define only @property. Any attempt to write raises AttributeError: property has no setter. Perfect for IDs, hashes, or values derived from other state.
🧮
Computed Property
derived from other attributes
No underlying stored value — the getter computes on every call. fahrenheit from celsius, full_name from first + last, age from a birth date.
📡
Two-Way Sync
setter updates underlying state
Powerful pattern: a property with both getter and setter that translates between views of the same underlying data. Set fahrenheit = 100 → underlying _celsius updates automatically.
class Person:
    def __init__(self, first, last, birth_year):
        self.first       = first
        self.last        = last
        self._birth_year = birth_year

    # ── COMPUTED read-only property ──
    @property
    def full_name(self):
        return f"{self.first} {self.last}"

    # ── READ-ONLY property (no setter) ──
    @property
    def birth_year(self):
        return self._birth_year

    # ── COMPUTED FROM current year — no storage ──
    @property
    def age(self, _year=2026):
        return _year - self._birth_year

alice = Person("Alice", "Roy", 1995)
print(alice.full_name)     # Alice Roy      (computed)
print(alice.age)           # 31             (computed)
print(alice.birth_year)    # 1995

# Change one component → dependents update automatically
alice.first = "Alicia"
print(alice.full_name)     # Alicia Roy    (recomputed on the fly)

# Read-only — trying to write fails
try:
    alice.birth_year = 1990
except AttributeError as e:
    print("Blocked:", e)
OUTPUT
Alice Roy 31 1995 Alicia Roy Blocked: property 'birth_year' of 'Person' object has no setter

Section 08

Practical Example 1 — Temperature With Two-Way Conversion

A classic use of properties: one stored value (celsius), multiple views of it (fahrenheit, kelvin). Set fahrenheit — the underlying celsius updates. Read celsius — you get the stored value directly. Every write is validated against absolute zero.

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

    # ══ CELSIUS: stored, with validation ═══════════════════════
    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError(f"{value}°C is below absolute zero")
        self._celsius = value

    @celsius.deleter
    def celsius(self):
        print("Resetting temperature to 0°C")
        self._celsius = 0

    # ══ FAHRENHEIT: two-way view of the underlying celsius ═════
    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        # Convert to celsius and reuse the celsius setter — validation reused!
        self.celsius = (value - 32) * 5 / 9

    # ══ KELVIN: read-only view of the underlying celsius ═══════
    @property
    def kelvin(self):
        return self._celsius + 273.15


t = Temperature(25)
print(f"{t.celsius}°C = {t.fahrenheit}°F = {t.kelvin}K")

# Write to fahrenheit → celsius updates too (two-way sync)
t.fahrenheit = 100
print(f"{t.celsius:.2f}°C = {t.fahrenheit}°F = {t.kelvin:.2f}K")

# Validation is enforced through EITHER path
try:
    t.celsius = -300
except ValueError as e:
    print("Blocked:", e)

try:
    t.fahrenheit = -500            # reuses celsius validation
except ValueError as e:
    print("Blocked:", e)

# Read-only kelvin cannot be assigned
try:
    t.kelvin = 400
except AttributeError as e:
    print("Blocked:", e)

del t.celsius                # deleter runs → resets to 0
print(f"After reset: {t.celsius}°C")
OUTPUT
25°C = 77.0°F = 298.15K 37.78°C = 100.0°F = 310.93K Blocked: -300°C is below absolute zero Blocked: -295.55555555555554°C is below absolute zero Blocked: property 'kelvin' of 'Temperature' object has no setter Resetting temperature to 0°C After reset: 0°C

Section 09

Practical Example 2 — BankAccount Balance With Property

class BankAccount:
    def __init__(self, holder, opening_balance):
        self.holder = holder
        self.balance = opening_balance    # goes THROUGH the setter
        self._log = []

    # ── Getter ────────────────────────────────────────────────
    @property
    def balance(self):
        return self._balance

    # ── Setter with full validation ───────────────────────────
    @balance.setter
    def balance(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("balance must be numeric")
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = float(value)

    # ── Deleter closes the account ────────────────────────────
    @balance.deleter
    def balance(self):
        print(f"Closing {self.holder}'s account, final balance {self._balance}")
        del self._balance

    # ── Read-only COMPUTED property ───────────────────────────
    @property
    def is_rich(self):
        return self._balance > 100_000

    # ── Read-only COMPUTED property ───────────────────────────
    @property
    def tier(self):
        if self._balance >= 1_000_000: return "Platinum"
        if self._balance >= 100_000:   return "Gold"
        if self._balance >= 10_000:    return "Silver"
        return "Standard"


acc = BankAccount("Alice", 5000)
print(acc.balance, acc.tier, acc.is_rich)      # 5000.0 Standard False

acc.balance = 150_000                              # setter validates then stores
print(acc.balance, acc.tier, acc.is_rich)      # 150000.0 Gold True

# Validation catches junk
try: acc.balance = -100
except ValueError as e: print("Blocked:", e)

try: acc.balance = "a lot"
except TypeError as e: print("Blocked:", e)

# Computed properties are read-only
try: acc.tier = "Platinum"
except AttributeError as e: print("Blocked:", e)

# Deleter runs custom cleanup
del acc.balance
OUTPUT
5000.0 Standard False 150000.0 Gold True Blocked: balance cannot be negative Blocked: balance must be numeric Blocked: property 'tier' of 'BankAccount' object has no setter Closing Alice's account, final balance 150000.0
🏆
Why Callers Never Notice the Machinery

Every line above uses acc.balance or acc.tier as if it were a plain attribute — no get_balance(), no set_balance(). But validation, computation, and cleanup all run automatically. That's the uniform access principle: from the caller's perspective, there's no difference between a stored value, a computed one, or a validated one. All three look like obj.attr.


Section 10

Common Mistakes (and Fixes)

MistakeWhat Goes WrongFix
Storing under the same name as the property self.age = value inside the setter — infinite recursion, stack overflow Use a different name: self._age = value (single underscore convention)
Forgetting to write to the underlying attribute The setter runs, validation passes — but nothing is stored End the setter with self._x = value
Using @setter instead of @name.setter Python doesn't recognise @setter as a decorator — NameError Always use the property's name: @balance.setter
Naming the setter differently from the getter Python treats them as unrelated — no property connection Setter must have the same name as the getter (they share the property object)
Adding a setter after the class body is defined Redefining the getter but forgetting the setter deletes the setter silently Define all three (getter, setter, deleter) contiguously in the class body
Doing expensive work in a getter Innocent-looking obj.total triggers a database query on every read Cache the result (e.g., functools.cached_property) or make it a method
Using @property when a method would be clearer Callers assume no side effects; you make an API call inside — surprise If it has side effects or takes arguments, use a plain method, not a property

Section 11

Quick Reference

TaskSyntaxNotes
Define a getter@property + def x(self):Read-only unless a setter follows
Define a setter@x.setter + def x(self, value):Must match the getter's name
Define a deleter@x.deleter + def x(self):Runs on del obj.x
Store underlying valueself._x = valueUnderscore prefix — universal convention
Access from callerobj.xSame as a plain attribute
Trigger the setterobj.x = valueJust an assignment — setter fires automatically
Trigger the deleterdel obj.xDeleter runs then attribute is gone
Read-only propertyOnly @property, no setterAssignment raises AttributeError
Computed propertyGetter returns a value derived from other attrsNo underlying storage needed
Expensive computed@functools.cached_propertyRuns once, caches the result

Section 12

Golden Rules

🏦 Properties, Getter, Setter & Deleter — Non-Negotiable Rules
1
A property is an attribute that runs a method. From outside, callers use obj.x — no parentheses, no method call syntax. From inside, you control what that means.
2
Start with a plain attribute. When you need validation, logging, or computation, upgrade to @propertywithout breaking the class's API. That's the whole point.
3
Store the underlying value under self._x, not self.x. Writing to self.x inside the setter would call the setter again — infinite recursion, immediate crash.
4
The setter's name must exactly match the getter's. The decorator is @x.setter — where x is the getter's function name. Different names mean two unrelated properties, not a getter/setter pair.
5
A property with only a getter is read-only. Writing to it raises AttributeError. Use this deliberately for values that shouldn't change after construction — IDs, hashes, derived data.
6
Validate inside the setter, not inside a separate method. Raising an exception from the setter guarantees bad data never touches the underlying attribute. It's the safest place to enforce class invariants.
7
Computed properties (like full_name, fahrenheit) recompute on every read. That's usually fine. If the computation is expensive, use @functools.cached_property to memoise the result.
8
Use a property when the operation is cheap, deterministic, and has no side effects. If it takes arguments, does I/O, or does anything surprising, make it a regular method — callers expect obj.x to be free.
9
The deleter is optional and rare. Use it only when del obj.x needs custom cleanup — resetting to a default, closing a resource, revoking a token. Otherwise leave it out.
10
Prefer @property over Java-style get_x()/set_x() methods. It's more Pythonic, callers get cleaner code, and you keep the freedom to change internal storage later without breaking anyone.