
Python Course
Encapsulation in Python
Encapsulation is one of the four pillars of object-oriented programming. It means bundling data and the methods that operate on that data inside a class — and controlling how that data is accessed and modified from outside. Done well, encapsulation protects your objects from invalid state, hides complexity, and lets you change internal implementation without breaking any code that uses your class.
Python takes a pragmatic approach: conventions and tools rather than hard enforcement. This lesson covers naming conventions, getters and setters, the @property decorator, computed properties, and the property deleter.
Why Encapsulation Matters
Without encapsulation, any code anywhere can reach into your object and set data to anything — including values that break your logic.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
acc = BankAccount("Alice", 500.00)
# Nothing prevents this — even though it makes no sense
acc.balance = -99999
print(acc.balance) # -99999 — invalid, but allowed
# Multiple accounts — any external code can corrupt any of them
acc.owner = "" # empty owner — also allowed
acc.balance = "hello" # wrong type — allowed, will crash laterEncapsulation solves this by making the object responsible for its own state — external code can only interact through a controlled interface.
1. Naming Conventions — Public, Protected, Private
Python signals intended visibility through naming conventions. There are no hard access keywords — the conventions are respected by developers and partially enforced by the language.
class Employee:
company = "Dataplexa" # public class attribute
def __init__(self, name, salary, ssn):
self.name = name # public — accessible anywhere
self._salary = salary # protected — internal use intended
self.__ssn = ssn # private — name-mangled by Python
def get_info(self):
return f"{self.name} | Salary: ${self._salary:,.2f}"
def verify_identity(self, ssn):
return self.__ssn == ssn # private used internally
emp = Employee("Alice", 85000, "123-45-6789")
print(emp.name) # Alice — fine
print(emp._salary) # 85000 — works, but discouraged
# print(emp.__ssn) # AttributeError — name mangled
# Name-mangled form — accessible but signals "you shouldn't"
print(emp._Employee__ssn) # 123-45-6789
print(emp.get_info())
print(emp.verify_identity("123-45-6789")) # True
print(emp.verify_identity("000-00-0000")) # False- Single underscore
_is a convention — Python does not restrict access at all. - Double underscore
__triggers name mangling:__ssninsideEmployeebecomes_Employee__ssn— accidental external access fails withAttributeError. - Python's philosophy: "We're all consenting adults here" — trust conventions rather than fighting them.
- Use
__sparingly — mainly to prevent subclass name collisions, not as a routine habit.
2. Getters and Setters
The traditional approach: explicit methods for controlled read and write access. Python supports this, though @property is generally preferred.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self._balance = 0.0
self.set_balance(balance) # runs validation immediately
def get_balance(self):
return self._balance
def set_balance(self, amount):
if not isinstance(amount, (int, float)):
raise TypeError(f"Balance must be a number, got {type(amount).__name__}.")
if amount < 0:
raise ValueError(f"Balance cannot be negative, got {amount}.")
self._balance = float(amount)
def deposit(self, amount):
self.set_balance(self._balance + amount)
print(f"Deposited ${amount:.2f}. Balance: ${self._balance:.2f}")
acc = BankAccount("Alice", 500.00)
print(acc.get_balance()) # 500.0
acc.deposit(200)
acc.set_balance(750)
print(acc.get_balance()) # 750.0
try:
acc.set_balance(-100)
except ValueError as e:
print("Error:", e)3. The @property Decorator — Pythonic Encapsulation
@property lets you expose an attribute with clean syntax while secretly running a method behind the scenes. The caller uses acc.balance — exactly like a plain attribute — and never knows validation is happening.
Key benefit: you can start with a plain public attribute, then later add validation by converting it to a property — without changing any code that uses it. The interface stays identical.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance # calls the setter immediately
@property
def balance(self):
"""Current account balance."""
return self._balance
@balance.setter
def balance(self, amount):
if not isinstance(amount, (int, float)):
raise TypeError(f"Balance must be a number.")
if amount < 0:
raise ValueError(f"Balance cannot be negative, got {amount}.")
self._balance = float(amount)
@balance.deleter
def balance(self):
print("Resetting balance to zero.")
self._balance = 0.0
def deposit(self, amount):
self.balance += amount # setter validates automatically
print(f"Balance: ${self.balance:.2f}")
acc = BankAccount("Alice", 500.00)
print(acc.balance) # 500.0 — clean attribute read
acc.deposit(250) # Balance: $750.00
del acc.balance # calls the deleter
print(acc.balance) # 0.0
try:
acc.balance = -50
except ValueError as e:
print("Error:", e)- The caller uses
acc.balance— identical to a plain attribute — no method calls needed. - Setting
self.balance = balancein__init__goes through the setter — validation runs at construction. - Store data in
self._balance, notself.balance— assigning toself.balanceinside the setter would call the setter recursively and crash. - Read-only property: define only the getter — any assignment raises
AttributeErrorautomatically. - The
@name.deleterdecorator defines what happens whendel obj.nameis called.
4. Computed Properties
A property does not have to read from stored data. It can compute a value on the fly from other attributes — always in sync, no manual update required.
class Order:
TAX_RATE = 0.08 # 8% tax — class attribute
def __init__(self, items):
self.items = items # list of (name, price, qty)
@property
def subtotal(self):
return sum(price * qty for _, price, qty in self.items)
@property
def tax(self):
return round(self.subtotal * self.TAX_RATE, 2)
@property
def total(self):
return round(self.subtotal + self.tax, 2)
@property
def item_count(self):
return sum(qty for _, _, qty in self.items)
order = Order([
("notebook", 4.99, 3),
("pen", 1.50, 10),
("desk", 89.99, 1),
])
print(f"Items : {order.item_count}")
print(f"Subtotal : ${order.subtotal:.2f}")
print(f"Tax : ${order.tax:.2f}")
print(f"Total : ${order.total:.2f}")
# Add an item — all computed properties update automatically
order.items.append(("lamp", 24.99, 2))
print(f"New total: ${order.total:.2f}")- Computed properties stay in sync automatically — update the underlying data and every derived value reflects the change instantly.
- No setter needed — naturally read-only since they are always derived.
- Cleaner than storing subtotal/tax/total separately and keeping them manually synchronised.
5. Complete Example — User Class
class User:
_count = 0
def __init__(self, username, email, age):
self.username = username # goes through setter
self.email = email
self.age = age
User._count += 1
@property
def username(self):
return self._username
@username.setter
def username(self, value):
if not value or not isinstance(value, str):
raise ValueError("Username must be a non-empty string.")
self._username = value.strip().lower()
@property
def email(self):
return self._email
@email.setter
def email(self, value):
if "@" not in str(value) or "." not in str(value):
raise ValueError(f"Invalid email: {value!r}")
self._email = value.lower().strip()
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int) or not (0 < value < 130):
raise ValueError("Age must be an integer between 1 and 129.")
self._age = value
@classmethod
def count(cls):
return cls._count
def __repr__(self):
return f"User({self.username!r}, {self.email!r}, age={self.age})"
u1 = User(" Alice ", "Alice@Example.COM", 30)
u2 = User("Bob", "bob@test.org", 25)
print(u1)
print(u2)
print(f"Total users: {User.count()}")
try:
u1.age = 200
except ValueError as e:
print("Error:", e)
# Can update via setter — validation still runs
u1.username = " ALICE_2 "
print(u1.username) # alice_2 — normalisedQuick Reference Table
| Tool / Convention | Syntax | Purpose |
|---|---|---|
| Public attribute | self.name | Accessible from anywhere |
| Protected attribute | self._name | Internal use — accessible but discouraged externally |
| Private attribute | self.__name | Name-mangled — prevents accidental external access |
| Getter / Setter | get_x() / set_x() | Explicit controlled access — verbose but clear |
@property | @property def name(self): | Read access with optional computation |
@name.setter | @name.setter def name(self, v): | Write access with validation |
@name.deleter | @name.deleter def name(self): | Custom behaviour on del obj.name |
Practice
What does a single underscore prefix on an attribute signal in Python?
What does Python do internally when you define self.__ssn inside class Employee?
Why is @property preferred over explicit getters and setters in Python?
If you define a property getter but no setter, what happens when code tries to assign to it?
Why does a property setter use self._balance for storage instead of self.balance?
Which decorator defines what happens when del obj.name is called on a property?
Quick Quiz
What is the main purpose of encapsulation?
What is the mangled name of self.__balance inside class BankAccount?
Which correctly defines a read-only property called area?
Why can you safely refactor a plain attribute into a @property without breaking existing callers?
A computed property like total derived from subtotal and tax — does it need a setter?
What decorator handles del obj.name for a property?
abc module — the fourth pillar of OOP.