DocsHub
Object-Oriented Programming

Encapsulation

Learn how to protect data in Python classes using private variables, getters, setters, and the property decorator.

Encapsulation

Imagine you walk into a bank and ask the teller to change your account balance directly — no deposit, no withdrawal, just "set my balance to one million". That would never happen. The bank protects its data and only allows changes through controlled, validated processes.

That is exactly what encapsulation is in OOP. It means:

  1. Hiding internal data from direct outside access
  2. Controlling how that data is read and changed through methods

Without encapsulation, anyone can reach into your object and change anything directly:

account = BankAccount("Ahmad", 10000)
account.balance = -999999   # nobody stops this

With encapsulation, you protect the data and force changes to go through proper validation.


The problem — unprotected data

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

account = BankAccount("Ahmad", 10000)

# anyone can do this — no validation, no rules
account.balance = -50000
account.owner = 999   # wrong type entirely

print(account.balance)   # -50000 — completely wrong

Nothing stops incorrect values from being set. This is a serious problem in real applications.


Private variables — name mangling with __

In Python, you make a variable private by adding double underscores before its name: __balance. This is called name mangling — Python internally renames it to _ClassName__balance, making it hard to access from outside:

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

account = BankAccount("Ahmad", 10000)

print(account.owner)       # Ahmad — public, accessible
print(account.__balance)   # AttributeError — private, not accessible

Python does not have true private variables like some other languages. __balance is still technically accessible via account._BankAccount__balance — but this is intentionally ugly. It signals clearly: you are breaking the rules, proceed at your own risk. In practice, nobody does this in real code.


Single underscore — a convention

A single underscore _balance is a softer signal — "this is internal, please do not touch it directly." It has no enforcement — it is purely a convention between developers:

class BankAccount:
    def __init__(self, owner, balance):
        self._balance = balance   # "please don't touch this directly"
StyleMeaningEnforced?
balancePublic — anyone can accessNo
_balanceInternal — please do not touchNo — convention only
__balancePrivate — name mangledPartially

Getters and setters — the manual way

Once data is private, you need controlled ways to read and change it. The traditional approach is getter and setter methods:

class BankAccount:
    bank_name = "HBL Bank"

    def __init__(self, owner, balance):
        self.owner = owner
        self.__balance = balance

    # getter — read the balance
    def get_balance(self):
        return self.__balance

    # setter — change the balance with validation
    def set_balance(self, amount):
        if not isinstance(amount, (int, float)):
            print("Balance must be a number.")
        elif amount < 0:
            print("Balance cannot be negative.")
        else:
            self.__balance = amount
            print(f"Balance updated to {self.__balance:,} PKR")


account = BankAccount("Ahmad", 10000)

# read balance
print(account.get_balance())   # 10000

# change balance — goes through validation
account.set_balance(25000)     # Balance updated to 25,000 PKR
account.set_balance(-500)      # Balance cannot be negative.
account.set_balance("hello")   # Balance must be a number.

This works — but it is not very Pythonic. You have to remember to call get_balance() and set_balance() every time. Python has a cleaner way.


@property — the Pythonic way

The @property decorator lets you access a method like it is a variable — no parentheses. Combined with a setter, you get clean access with full validation:

class BankAccount:
    bank_name = "HBL Bank"

    def __init__(self, owner, balance):
        self.owner = owner
        self.__balance = balance

    @property
    def balance(self):
        """The account balance — read only access."""
        return self.__balance

    @balance.setter
    def balance(self, amount):
        if not isinstance(amount, (int, float)):
            raise TypeError("Balance must be a number.")
        if amount < 0:
            raise ValueError("Balance cannot be negative.")
        self.__balance = amount

Now you use it like a normal variable — but all validation runs automatically:

account = BankAccount("Ahmad", 10000)

# read — calls the @property getter
print(account.balance)   # 10000

# write — calls the @balance.setter
account.balance = 25000
print(account.balance)   # 25000

# invalid — raises ValueError
account.balance = -500   # ValueError: Balance cannot be negative.

Clean, readable, and safe.

@property is the modern Python standard. Use it instead of manual get_balance() / set_balance() methods. It gives you the clean syntax of direct attribute access with the protection of a method.


Read-only properties

If you define a @property without a setter, the attribute becomes read-only:

class BankAccount:
    bank_name = "HBL Bank"

    def __init__(self, owner, balance):
        self.__owner = owner
        self.__balance = balance
        self.__account_number = f"HBL-{id(self) % 100000:05d}"

    @property
    def owner(self):
        return self.__owner

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

    @property
    def account_number(self):
        """Read-only — generated once, never changes."""
        return self.__account_number


account = BankAccount("Ahmad", 10000)

print(account.owner)           # Ahmad
print(account.balance)         # 10000
print(account.account_number)  # HBL-12345 (some number)

# trying to change a read-only property raises AttributeError
account.account_number = "HBL-99999"   # AttributeError: can't set attribute

@property.deleter — controlling deletion

You can also control what happens when someone tries to delete an attribute:

class BankAccount:
    def __init__(self, owner, balance):
        self.__owner = owner
        self.__balance = balance

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

    @balance.deleter
    def balance(self):
        raise AttributeError("Balance cannot be deleted.")


account = BankAccount("Ahmad", 10000)
del account.balance   # AttributeError: Balance cannot be deleted.

A fully encapsulated BankAccount

Bringing it all together — a properly protected bank account:

class BankAccount:
    bank_name = "HBL Bank"
    __total_accounts = 0   # private class variable

    def __init__(self, owner, opening_balance):
        self.__owner = owner
        self.__balance = 0
        self.__transactions = []

        # use the setter for validation on creation too
        self.balance = opening_balance
        BankAccount.__total_accounts += 1

    # --- Properties ---

    @property
    def owner(self):
        return self.__owner

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

    @balance.setter
    def balance(self, amount):
        if not isinstance(amount, (int, float)):
            raise TypeError("Balance must be a number.")
        if amount < 0:
            raise ValueError("Balance cannot be negative.")
        self.__balance = amount

    @property
    def transactions(self):
        return list(self.__transactions)   # return a copy — not the original

    # --- Instance methods ---

    def deposit(self, amount):
        if not isinstance(amount, (int, float)) or amount <= 0:
            print("Deposit amount must be a positive number.")
            return
        self.__balance += amount
        self.__transactions.append(("deposit", amount))
        print(f"Deposited {amount:,} PKR → Balance: {self.__balance:,} PKR")

    def withdraw(self, amount):
        if not isinstance(amount, (int, float)) or amount <= 0:
            print("Withdrawal amount must be a positive number.")
            return
        if amount > self.__balance:
            print(f"Insufficient balance. Available: {self.__balance:,} PKR")
            return
        self.__balance -= amount
        self.__transactions.append(("withdrawal", amount))
        print(f"Withdrawn {amount:,} PKR → Balance: {self.__balance:,} PKR")

    def statement(self):
        print(f"\n{'=' * 40}")
        print(f"  {BankAccount.bank_name} — Account Statement")
        print(f"{'=' * 40}")
        print(f"  Owner:   {self.__owner}")
        print(f"  Balance: {self.__balance:,} PKR")
        print(f"{'─' * 40}")
        if not self.__transactions:
            print("  No transactions yet.")
        else:
            for t_type, t_amount in self.__transactions:
                symbol = "+" if t_type == "deposit" else "-"
                print(f"  {symbol} {t_amount:,} PKR  ({t_type})")
        print(f"{'=' * 40}\n")

    # --- Class methods ---

    @classmethod
    def change_bank_name(cls, new_name):
        cls.bank_name = new_name
        print(f"Bank name changed to: {cls.bank_name}")

    @classmethod
    def get_total_accounts(cls):
        return cls.__total_accounts

    # --- Static methods ---

    @staticmethod
    def validate_amount(amount):
        return isinstance(amount, (int, float)) and amount > 0

    @staticmethod
    def convert_to_usd(pkr_amount, rate=280):
        return round(pkr_amount / rate, 2)

Using the complete encapsulated account:

account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Sara", 25000)

account1.deposit(5000)
account1.deposit(3000)
account1.withdraw(2000)
account1.withdraw(99999)   # blocked — insufficient balance

account1.statement()

# reading properties — clean access
print(account1.owner)    # Ahmad
print(account1.balance)  # 16000

# trying to break the rules
account1.balance = -500  # ValueError: Balance cannot be negative.

# class and static methods still work
BankAccount.change_bank_name("MCB Bank")
print(BankAccount.get_total_accounts())    # 2
print(BankAccount.convert_to_usd(28000))  # 100.0

Output:

Deposited 5,000 PKR → Balance: 15,000 PKR
Deposited 3,000 PKR → Balance: 18,000 PKR
Withdrawn 2,000 PKR → Balance: 16,000 PKR
Insufficient balance. Available: 16,000 PKR

========================================
  HBL Bank — Account Statement
========================================
  Owner:   Ahmad
  Balance: 16,000 PKR
────────────────────────────────────────
  + 5,000 PKR  (deposit)
  + 3,000 PKR  (deposit)
  - 2,000 PKR  (withdrawal)
========================================

Ahmad
16000
Bank name changed to: MCB Bank
2
100.0

Why return a copy of transactions?

Notice this line in the transactions property:

return list(self.__transactions)   # return a copy — not the original

If you returned self.__transactions directly, the caller could modify the list and bypass all your protection:

# without copy — dangerous
txns = account.transactions
txns.append(("deposit", 999999999))   # modifies the real list!

# with copy — safe
txns = account.transactions
txns.append(("deposit", 999999999))   # modifies only the copy

Always return copies of mutable internal data.


Summary

ConceptExample
Public attributeself.owner
Private attributeself.__balance
Convention — internalself._balance
Read a private value@property
Write with validation@property_name.setter
Read-only property@property with no setter
Prevent deletion@property_name.deleter

On this page