Skip to content

Encapsulation in Python

What is Encapsulation?

Encapsulation means:
  (a) BUNDLING data (variables) and the methods that work on that data
      inside one unit (the class), and
  (b) HIDING the internal data so outside code cannot change it
      directly. Outside code must go through methods we control.

Real-Life Analogy

An ATM machine. You cannot open the machine and take cash from the box yourself. You must use the screen and keypad (the "public methods"), which check your PIN and your balance first.

ACCESS LEVELS IN PYTHON (by naming convention)
----------------------------------------------
    name        -> PUBLIC     : use it from anywhere
    _name       -> PROTECTED  : "internal, please don't touch" (convention only)
    __name      -> PRIVATE    : Python renames it (name mangling) so it is
                                hard to reach from outside the class

NOTE: Python has no true "private" keyword like Java. It trusts the programmer ("we are all consenting adults"). The underscores are signals plus a little name-mangling protection.

The Problem - No Encapsulation

Everything is public. Any code can set the balance to anything, even a negative number. There is no protection at all.

class UnsafeAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance


acc = UnsafeAccount("Ravi", 5000)
acc.balance = -999999          # nobody stops this!
print("  Unsafe balance:", acc.balance, " <- invalid data got in")

Public Members

Normal names. Fine for data that is SAFE to read and change freely. Industry example: a blog post's title can be edited by anyone.

class BlogPost:
    def __init__(self, title, author):
        self.title = title          # public
        self.author = author        # public

    def publish(self):              # public method
        print(f"  Published '{self.title}' by {self.author}")


post = BlogPost("Learning OOPS", "Sudheer")
post.title = "Learning Python OOPS"     # allowed and fine
post.publish()

Protected Members (_single underscore)

One underscore means: "This is internal. Child classes may use it, but outside code should not." Python does NOT block access; it is a convention that every Python developer respects. Industry example: an internal retry counter in an API client.

class ApiClient:
    def __init__(self, base_url):
        self.base_url = base_url
        self._retry_count = 3        # protected - internal setting

    def fetch(self, endpoint):
        print(f"  GET {self.base_url}{endpoint} (max retries: {self._retry_count})")


class PaymentApiClient(ApiClient):
    def __init__(self, base_url):
        super().__init__(base_url)
        self._retry_count = 5        # OK: a child class may use protected data


PaymentApiClient("https://api.shop.com").fetch("/payments")
client = ApiClient("https://api.shop.com")
print("  Outside access still works (but is bad practice):", client._retry_count)

Private Members (__double underscore) + Name Mangling

Two leading underscores make Python RENAME the attribute internally:

__balance   becomes   _BankAccount__balance

So obj.__balance from outside raises AttributeError. Industry example: a bank balance must only change via deposit/withdraw.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner           # public
        self.__balance = balance     # private

    def deposit(self, amount):
        if amount <= 0:
            print("  Deposit rejected: amount must be positive")
            return
        self.__balance += amount
        print(f"  Deposited Rs.{amount}. New balance: Rs.{self.__balance}")

    def withdraw(self, amount):
        if amount > self.__balance:
            print(f"  Withdraw rejected: insufficient funds (balance Rs.{self.__balance})")
            return
        self.__balance -= amount
        print(f"  Withdrew Rs.{amount}. New balance: Rs.{self.__balance}")


account = BankAccount("Priya", 10000)
account.deposit(2000)
account.deposit(-50)
account.withdraw(50000)
account.withdraw(3000)

try:
    print(account.__balance)             # cannot read it directly
except AttributeError as err:
    print("  ERROR:", err)

# Teaching point: name mangling is protection, not a lock.
# The mangled name still exists, which shows how Python works internally.
print("  Mangled name exists:", account._BankAccount__balance, "(never do this in real code)")

# Another trap: assigning obj.__balance from OUTSIDE creates a NEW
# public attribute. It does NOT change the real private balance.
account.__balance = 1
print("  After 'account.__balance = 1' real balance is still:",
      account._BankAccount__balance)

Getter and Setter Methods (Traditional Style)

A getter returns private data; a setter changes it AFTER validating. This style is common in Java, and you will see it in older Python code. Industry example: an employee's salary in a payroll system.

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.__salary = 0
        self.set_salary(salary)          # reuse the validation logic

    def get_salary(self):
        return self.__salary

    def set_salary(self, value):
        if value < 0:
            raise ValueError("Salary cannot be negative")
        self.__salary = value


emp = Employee("Anil", 60000)
print("  Salary:", emp.get_salary())
emp.set_salary(65000)
print("  After raise:", emp.get_salary())
try:
    emp.set_salary(-100)
except ValueError as err:
    print("  ERROR:", err)

@property - the Pythonic Way

@property lets us keep the simple syntax obj.salary and obj.salary = 5000 , while Python secretly calls our getter/setter. The user of the class does not even know validation is happening. Industry example: product stock in an inventory system.

class InventoryItem:
    def __init__(self, name, stock):
        self.name = name
        self.stock = stock               # this line already calls the setter below

    @property
    def stock(self):                     # GETTER -> obj.stock
        return self.__stock

    @stock.setter
    def stock(self, value):              # SETTER -> obj.stock = value
        if not isinstance(value, int):
            raise TypeError("Stock must be a whole number")
        if value < 0:
            raise ValueError("Stock cannot be negative")
        self.__stock = value


item = InventoryItem("USB Cable", 50)
print("  Stock:", item.stock)                # looks like a normal attribute
item.stock = 45                              # goes through validation
print("  Updated stock:", item.stock)

for bad_value in (-5, 2.5):
    try:
        item.stock = bad_value
    except (ValueError, TypeError) as err:
        print(f"  ERROR setting {bad_value!r}:", err)

Read-Only (Computed) Properties

A property WITHOUT a setter is read-only. It is often calculated from other data, so it can never be out of date. Industry example: an e-commerce order total, calculated from its lines.

class Order:
    GST_RATE = 0.18

    def __init__(self, order_id):
        self.order_id = order_id
        self.__lines = []                # private list of (name, price, qty)

    def add_item(self, name, price, qty=1):
        self.__lines.append((name, price, qty))

    @property
    def subtotal(self):
        return sum(price * qty for _, price, qty in self.__lines)

    @property
    def tax(self):
        return round(self.subtotal * Order.GST_RATE, 2)

    @property
    def total(self):
        return self.subtotal + self.tax


order = Order("ORD-501")
order.add_item("Headphones", 1500)
order.add_item("Phone Case", 300, 2)
print(f"  Subtotal: {order.subtotal} | GST: {order.tax} | Total: {order.total}")

try:
    order.total = 10               # no setter -> cannot be changed
except AttributeError as err:
    print("  ERROR:", err)

Property Deleter

@x.deleter runs when someone writes del obj.x Industry example: logging out clears a user's auth token safely.

class UserLogin:
    def __init__(self, username, token):
        self.username = username
        self.__token = token

    @property
    def token(self):
        if self.__token is None:
            return "<logged out>"
        return self.__token[:4] + "****"      # never expose the full token

    @token.deleter
    def token(self):
        print(f"  Logging out {self.username}: token cleared")
        self.__token = None


login = UserLogin("sudheer", "abcd1234secret")
print("  Token:", login.token)
del login.token
print("  Token:", login.token)

Private Helper Methods

Methods can be private too. These are internal steps that outside code should never call on their own. Industry example: sending an OTP. Generating and storing the OTP are internal steps; the public method is just send_otp().

import random


class OtpService:
    def __init__(self):
        self.__sent_otps = {}

    def __generate_otp(self):                 # private method
        return random.randint(100000, 999999)

    def __mask_phone(self, phone):            # private method
        return "******" + phone[-4:]

    def send_otp(self, phone):                # public method (the only entry point)
        otp = self.__generate_otp()
        self.__sent_otps[phone] = otp
        print(f"  OTP sent to {self.__mask_phone(phone)}")

    def verify(self, phone, otp):
        return self.__sent_otps.get(phone) == otp


service = OtpService()
service.send_otp("9876543210")
print("  Verify wrong OTP:", service.verify("9876543210", 111111))
try:
    service.__generate_otp()
except AttributeError:
    print("  ERROR: __generate_otp() is private and cannot be called from outside")

Industry Example - Payment Card

This puts it all together:

* card number is private and only shown MASKED
* PIN is private and can never be read, only checked
* card is blocked after 3 wrong PIN attempts
* balance is read-only from outside and changes only via pay()
class PaymentCard:
    MAX_ATTEMPTS = 3

    def __init__(self, holder, card_number, pin, balance):
        self.holder = holder                 # public
        self.__card_number = card_number     # private
        self.__pin = pin                     # private
        self.__balance = balance             # private
        self._failed_attempts = 0            # protected
        self._blocked = False                # protected

    @property
    def card_number(self):
        return "XXXX-XXXX-XXXX-" + self.__card_number[-4:]

    @property
    def balance(self):
        return self.__balance

    def __check_pin(self, pin):
        if self._blocked:
            print("  Card is BLOCKED. Contact the bank.")
            return False
        if pin == self.__pin:
            self._failed_attempts = 0
            return True
        self._failed_attempts += 1
        left = PaymentCard.MAX_ATTEMPTS - self._failed_attempts
        if left == 0:
            self._blocked = True
            print("  Wrong PIN. Card is now BLOCKED.")
        else:
            print(f"  Wrong PIN. {left} attempt(s) left.")
        return False

    def pay(self, amount, pin):
        if not self.__check_pin(pin):
            return
        if amount > self.__balance:
            print("  Payment declined: insufficient balance")
            return
        self.__balance -= amount
        print(f"  Paid Rs.{amount} with {self.card_number}. Balance: Rs.{self.__balance}")


card = PaymentCard("Lakshmi", "4111222233334444", "4321", 20000)
print("  Card:", card.card_number)
card.pay(2500, "4321")        # correct PIN
card.pay(100, "0000")         # wrong 1
card.pay(100, "1111")         # wrong 2
card.pay(100, "2222")         # wrong 3 -> blocked
card.pay(100, "4321")         # even the correct PIN fails now
print("  Balance (read-only):", card.balance)

Quick Revision

  • Encapsulation = data + methods in one class + controlled access.
  • name -> public, _name -> protected (convention), __name -> private (name mangled to _ClassName__name).
  • Use @property for validation while keeping simple obj.attr syntax.
  • A property without a setter is read-only.
  • Expose only what is needed. Hide the rest (PIN, tokens, balance logic).

Practice Questions

  1. Create a Student class with a private __marks and a property that
  2. Add a read-only property "grade" calculated from marks.
  3. Create a Password class that stores a password privately and has