Private Attributes, @property & Setters Explained - Python OOP Encapsulation Tutorial #21
Video: Private Attributes, @property & Setters Explained - Python OOP Encapsulation Tutorial #21 by Taught by Celeste AI - AI Coding Coach
Python Encapsulation: _private, __mangled, @property, setters
Python has no real
private— just conventions._namesays "internal, don't touch."__nametriggers name-mangling (prevents subclass collisions).@propertyturns method calls into attribute access.@x.setteradds validation on assignment.
Encapsulation hides implementation details. Python's approach is trust-based: convention over enforcement.
Public attributes (the default)
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
buddy = Dog("Buddy", "Golden Retriever")
print(buddy.name)
buddy.name = "Max" # totally fine
By default, every attribute is public. Anyone can read or write.
For most data, this is fine. Don't hide things just because a Java tutorial said to.
Protected attributes: single underscore
class Employee:
def __init__(self, name, salary):
self.name = name
self._salary = salary # convention: internal
def get_salary(self):
return f"${self._salary:,}"
emp = Employee("Alice", 75000)
print(emp._salary) # works — but you're not supposed to
_salary is a convention: "this is internal; don't access directly." Python doesn't enforce it. Linters and type checkers might warn.
The single underscore says "don't touch this from outside the class — the implementation may change."
Private attributes: double underscore (name mangling)
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance # name-mangled
def get_balance(self):
return f"${self.__balance:,.2f}"
acct = BankAccount("Bob", 5000)
print(acct.__balance)
# AttributeError: 'BankAccount' object has no attribute '__balance'
# But it's still accessible:
print(acct._BankAccount__balance)
# 5000 — Python mangled the name
Names starting with __ (but not ending with __) are name-mangled: __balance becomes _BankAccount__balance automatically. From outside, acct.__balance doesn't find anything.
This isn't security — it's collision-avoidance. The point is: if a subclass or external code defines __balance, it won't accidentally clash with BankAccount.__balance.
For most code: use _name (one underscore). __name is for the rare case where collisions would be a real problem.
@property: method as attribute
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
temp = Temperature(25)
print(temp.celsius) # 25 — looks like attribute access
print(temp.fahrenheit) # 77.0 — but it's a method call
@property turns a method into a getter. Callers write temp.fahrenheit instead of temp.fahrenheit(). The method runs each time.
This lets you change implementation without breaking callers — start with a plain attribute, switch to a property when you need computation or validation.
Setter: @x.setter
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
temp = Temperature(25)
temp.celsius = 100 # calls the setter
temp.celsius = -300 # ValueError
@celsius.setter defines what happens on temp.celsius = .... Validate, transform, log — whatever.
The pattern:
@property
def x(self): # getter
return self._x
@x.setter
def x(self, value): # setter
# validate
self._x = value
Note the convention: store the actual value in self._x (with underscore), expose it as self.x (no underscore).
Read-only property: no setter
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.area)
c.area = 100 # AttributeError: can't set attribute
area has only a getter — no setter, so it's read-only. Setting raises AttributeError.
This is perfect for derived values: area depends on radius; you can't set it directly.
Deleter
@x.deleter
def x(self):
del self._x
del temp.x calls the deleter. Rare; mostly used to clean up resources.
Why @property over getter/setter methods?
Java-style:
class Temperature:
def get_celsius(self):
return self._celsius
def set_celsius(self, value):
self._celsius = value
# Caller:
temp.set_celsius(100)
print(temp.get_celsius())
Python-style:
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
self._celsius = value
# Caller:
temp.celsius = 100
print(temp.celsius)
Same behavior, cleaner call site. Plus you can start with a plain attribute and migrate to a property without touching callers — the holy grail of API stability.
Conventions summary
| Name | Meaning |
|---|---|
name |
Public — part of the API |
_name |
Protected — internal, don't touch |
__name |
Private — name-mangled |
__name__ |
Dunder — Python special method |
name_ |
Trailing underscore to avoid keyword collision (class_, type_) |
A bank account with full encapsulation
class BankAccount:
def __init__(self, owner, initial_deposit):
self.owner = owner
self._balance = 0
self._transactions = []
self.deposit(initial_deposit)
@property
def balance(self):
return self._balance
@property
def transaction_count(self):
return len(self._transactions)
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
self._transactions.append(("deposit", 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
self._transactions.append(("withdraw", amount))
balance is read-only (no setter). Modifications go through deposit/withdraw, which validate and log. The interface is small and safe.
Common stumbles
Confusing __name with __name__. Double underscore on both sides is a "dunder" (special method) — __init__, __str__, etc. Name-mangling only happens with leading-but-not-trailing __.
Setter without getter. @x.setter requires x to already be a property. Define the getter first.
Forgetting _x in property. self.x = value inside a setter for x recurses infinitely. Always use a different attribute name (self._x).
Treating _x as enforced private. Anyone can still access it. The convention is a hint, not a guard.
Heavy property with side effects. temp.fahrenheit looks instant — but if it does network IO, callers will be surprised. Make properties cheap, predictable, no side effects.
__name for "real" privacy. Name mangling isn't security — _BankAccount__balance is still accessible. Don't store secrets in attributes.
What's next
Lesson 22: abstract classes and operator overloading. abc.ABC, __add__, __eq__, __lt__, etc.
Recap
Python uses naming conventions, not enforcement: _name for "internal," __name for name-mangled (collision avoidance, not security). @property turns a method into attribute access; @x.setter adds an assignment hook. Use properties for validation, derived values, and migrating from plain attributes without breaking the API. Read-only = property without setter.
Next lesson: abstract classes and operator overloading.