Encapsulation in Python

Let's build a piggy-bank class: deposit adds money and refuses a negative amount. The check is there — but getting around it is trivial:

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

    def deposit(self, amount):
        if amount > 0:
            self.balance += amount

account = BankAccount(1000)

account.deposit(-500)          # the check won't let this through
print(account.balance)
1000
account.balance = -1_000_000   # but this — go right ahead
print(account.balance)
-1000000

The check in deposit hasn't gone anywhere — we simply bypassed it: balance is a regular attribute, and you can assign anything to it. The class cannot enforce its own rules — for example, "the balance is never negative".

Encapsulation is the answer to this problem: an object has a public interface (how the outside world talks to it) and internal state (which the outside world shouldn't poke). External code calls methods; the class itself makes sure its data stays correct.

The single-underscore convention

How do you tell internal from public? Python has a convention for that: an attribute or method whose name starts with an underscore is considered "internal" — don't touch it from outside:

Python 3.13
class BankAccount:
    def __init__(self, balance):
        self._balance = balance   # underscore = "internal"

    def get_balance(self):
        return self._balance

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount

    def withdraw(self, amount):
        if 0 < amount <= self._balance:
            self._balance -= amount

account = BankAccount(1000)
account.deposit(500)
account.withdraw(2000)        # more than the balance, ignored
print(account.get_balance())
1500

From the outside only deposit, withdraw, and get_balance are available: everything you need to work with the account. You technically can still poke _balance from outside (Python doesn't forbid it), but the convention says: don't, otherwise you're bypassing the class's checks.

The Python community states this philosophy as "we're all consenting adults here". The language doesn't forbid — it signals that you shouldn't touch this. Responsibility is on the programmer.

outside code
through methods
directly
class BankAccount
deposit()withdraw()get_balance()
internal state_balance
The account is used through its methods, while _balance is changed only by the class itself

Properties: an attribute on the outside, a method on the inside

Often you want account.balance to look like a regular attribute from the outside, while underneath there's actually a method with validation. That's what @property is for.

The line with @ above a method is a decorator: a device that changes how the method behaves. Decorators get their own lesson later in the course; for now it's enough to know what these two do:

Python 3.13
class Account:
    def __init__(self, balance):
        self._balance = balance

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

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

account = Account(1000)

# Used like a regular attribute:
print(account.balance)
1000
account.balance = 500   # triggers the setter with validation
print(account.balance)
500
# Trying to set a negative value:
try:
    account.balance = -100
except ValueError as e:
    print(f"Error: {e}")
Error: Balance cannot be negative

@property turns a method into a "computed attribute": from the outside, account.balance is read without parentheses. @balance.setter defines what happens on assignment: from the outside it looks like a plain assignment, but inside the class the validation kicks in.

The example uses raise and try/except — that's the error mechanism: raise aborts the operation and signals that a value is invalid, while try/except catches it in the calling code. They have their own lesson later in the course; here it's enough to see that the check in the setter prevents an invalid value from being stored.

Read-only properties

If a @property has no setter, the attribute becomes read-only. This is handy for computed values that don't make sense to "set" from outside:

Python 3.13
import math

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

    @property
    def area(self):
        return math.pi * self.radius ** 2

circle = Circle(5)
print(f"Radius: {circle.radius}, area: {round(circle.area, 2)}")
Radius: 5, area: 78.54
# You can't assign to area (there's no setter)
try:
    circle.area = 100
except AttributeError as e:
    print(f"Error: {e}")
Error: property 'area' of 'Circle' object has no setter

area always returns the current value, and you can't assign to it — rightly so: the area isn't stored separately, it follows from the radius.

What encapsulation gives you

The main point: the class becomes responsible for its own data. From outside there's no way (by convention) to bypass its checks and leave the object with incorrect data. If later you need to change how the data is stored (say, _balance becomes a dict with a transaction history), the outside code doesn't break, because it still talks to the class through the same public interface — deposit, withdraw, balance.

Understanding check

How do you set up an attribute that can be read from outside but not changed?

In the next lesson we'll take on the third principle of OOP — polymorphism: how a single interface can work with objects of different classes, and why that makes code simpler.