bigonotes
CS ConceptsOOP

Encapsulation

How to hide an object's data and control access to it in Python with naming conventions, getters/setters and @property.

3 min readLast updated OOPListen · Watch on NotebookLM

Encapsulation means hiding an object's internal data and controlling access to it through methods. Callers use what the object exposes and never need to know how it works inside. Think of an ATM: you press buttons and get cash, but you never touch the bank's internal records.

Access levels in Python#

Python controls access purely by naming convention:

class BankAccount:
    def __init__(self, balance):
        self.balance = balance      # public
        self._balance = balance     # protected (by convention)
        self.__balance = balance    # private (name-mangled)
Syntax Level Reachable from outside? Meant for
balance Public Yes Anyone
_balance Protected Yes, but you shouldn't The class and its subclasses
__balance Private Only via the mangled name The class itself

Python has no true private#

Unlike languages such as C++, Python does not strictly enforce privacy. A double-underscore name is quietly renamed to _ClassName__name. This is name mangling.

acc = BankAccount(1000)
print(acc.__balance)              # AttributeError
print(acc._BankAccount__balance)  # 1000: still reachable

Python's philosophy is "we are all consenting adults here": it trusts you to respect conventions instead of locking things down. Name mangling mainly prevents accidental clashes in subclasses.

Why public attributes are risky#

If an attribute is public, anyone can set it to anything, including invalid values:

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

acc = BankAccount(1000)
acc.balance = -5000  # nothing stops this

Getters and setters#

Make the attribute private and expose methods that validate changes:

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

    def get_balance(self):
        return self.__balance

    def set_balance(self, amount):
        if amount < 0:
            print("Invalid! Balance can't be negative")
        else:
            self.__balance = amount

acc = BankAccount(1000)
acc.set_balance(-500)       # Invalid! Balance can't be negative
acc.set_balance(2000)
print(acc.get_balance())    # 2000

@property: the Pythonic way#

Calling get_balance() works but feels clunky. @property lets callers use plain attribute syntax while your validation still runs:

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

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

    @balance.setter
    def balance(self, amount):      # setter with validation
        if amount < 0:
            print("Invalid! Balance can't be negative")
        else:
            self.__balance = amount

acc = BankAccount(1000)
print(acc.balance)   # 1000 (getter runs)
acc.balance = -500   # Invalid! Balance can't be negative
acc.balance = 2000   # setter runs
Getters and setters @property
Read acc.get_balance() acc.balance
Write acc.set_balance(2000) acc.balance = 2000
Feels like A method call Attribute access
Idiomatic Python? Not really Yes

A useful consequence: you can start with a plain public attribute and later turn it into a property without changing any caller.

Read-only and computed properties#

Define only the getter and the attribute becomes read-only. Properties can also compute values on the fly.

import math

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

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

    @property
    def area(self):  # computed each time, read-only
        return math.pi * self.__radius ** 2

c = Circle(5)
print(c.radius)  # 5
print(c.area)    # 78.53...
c.radius = 10    # AttributeError: no setter defined

Full example#

A bank account brings the pieces together:

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

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

    @balance.setter
    def balance(self, amount):
        if amount < 0:
            print("Invalid! Balance can't be negative")
        else:
            self.__balance = amount

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"Deposited {amount}. New balance: {self.__balance}")

    def withdraw(self, amount):
        if amount <= 0:
            print("Invalid amount!")
        elif amount > self.__balance:
            print("Insufficient funds!")
        else:
            self.__balance -= amount
            print(f"Withdrew {amount}. New balance: {self.__balance}")

acc = BankAccount("Bruno", 1000)
acc.deposit(500)    # Deposited 500. New balance: 1500
acc.withdraw(200)   # Withdrew 200. New balance: 1300
acc.balance = -100  # Invalid! Balance can't be negative
acc.withdraw(9999)  # Insufficient funds!

Notice that deposit and withdraw are the real interface. Exposing operations rather than raw setters keeps the rules (no overdraft, positive amounts) inside the class.

Key takeaways#

  • Encapsulation = hide data + control access through methods.
  • Python privacy is convention: _x means "internal", __x triggers name mangling to _ClassName__x.
  • Use @property for validated access that still looks like a plain attribute.
  • A property with no setter is read-only; properties can also be computed.
  • Prefer exposing meaningful operations (deposit) over raw setters.

Found a mistake? Tell me on LinkedIn ↗

Further reading#