Object-Oriented ProgrammingIntermediate8 min42 / 63

Encapsulation

Learn how to bundle data with methods and control who can touch what inside your classes.

Imagine you own a vending machine. It has money inside, a stock of snacks, and a bunch of mechanical parts. You don't want customers reaching in and grabbing cash or swapping parts around — you only want them interacting through the buttons on the front.

That idea — bundling data together with the actions that use it, and controlling what the outside world can touch — is called encapsulation. It is one of the core ideas in object-oriented programming, and Python has a clean way to express it.

See it in action

Visual walkthrough1 / 6
1

Your Data, Your Rules

Encapsulation means bundling data and the methods that use it into one unit — and deciding what the outside world is allowed to touch. Think of a vending machine: customers press buttons, they don't reach inside.

Encapsulation is one of the four pillars of object-oriented programming.

#Bundling Data and Methods Together

A class already does the bundling for you. When you put attributes and methods inside a class, they travel together as one unit. That is the first half of encapsulation.

Here is a simple BankAccount class that keeps a balance and exposes only safe operations:

Data (owner, balance) and behaviour (deposit, get_balance) live together.
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

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

    def get_balance(self):
        return self.balance

account = BankAccount("Alice", 100)
account.deposit(50)
print(account.get_balance())

Right now balance is fully public — anyone can read or change it directly. That may be fine for simple scripts, but as code grows you usually want tighter control. That is the second half of encapsulation: access control.

#Public, Protected, and Private: Python's Conventions

Python does not have a private keyword like some other languages. Instead, it uses naming conventions that are universally respected by Python programmers:

  • No underscore (name) — public. Anyone can use it.
  • Single underscore (_name) — protected. It says: "this is internal; use it only if you really know what you are doing."
  • Double underscore (__name) — private. Python applies name mangling to make accidental access harder.
Think of it like

Think of it like a house

  • Public attributes are your front porch — everyone is welcome.
  • Protected attributes are your living room — family and close friends, not strangers.
  • Private attributes are your safe — you decide who gets the combination.
Underscores communicate intent to other programmers.
class Employee:
    def __init__(self, name, salary):
        self.name = name          # public
        self._department = "Engineering"  # protected
        self.__salary = salary    # private (name-mangled)

emp = Employee("Bob", 80000)
print(emp.name)          # fine
print(emp._department)   # works but signals "be careful"
# print(emp.__salary)    # AttributeError!

#Name Mangling Up Close

When Python sees __salary inside a class named Employee, it quietly renames it to _Employee__salary. This is called name mangling. It stops subclasses from accidentally overwriting the same attribute, and it makes naive outside access fail with an AttributeError.

You can still get to it if you know the mangled name — Python trusts you not to abuse that.

Name mangling creates _ClassName__attr, not true invisibility.
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.__salary = salary

emp = Employee("Bob", 80000)
# The mangled name still works if you need it:
print(emp._Employee__salary)
Note

Python trusts you

Python's philosophy is "we are all consenting adults here." There is no hard wall. The underscore conventions are a social contract — they say "please don't touch this", not "you cannot touch this." Respect them and expect others to do the same.

#Protecting Invariants With Methods

The real reason to hide data is to protect invariants — rules that must always be true. For example, a bank balance should never go negative. If balance is public, anyone can write account.balance = -999. If you hide it and only allow changes through withdraw(), you can enforce the rule in one place.

All balance changes go through methods that check the rules.
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.__balance += amount

    def withdraw(self, amount):
        if amount > self.__balance:
            raise ValueError("Not enough funds")
        self.__balance -= amount

    def get_balance(self):
        return self.__balance

acc = BankAccount("Alice", 200)
acc.deposit(50)
acc.withdraw(30)
print(acc.get_balance())

#The @property Decorator

Calling get_balance() works, but Python has a cleaner way: the `@property` decorator. It lets you expose a private attribute as if it were a plain attribute, while still running code when someone reads or writes it.

You write one method for getting (reading) and optionally one for setting (writing).

@property makes attribute access look clean while hiding the logic.
class Temperature:
    def __init__(self, celsius):
        self.__celsius = celsius

    @property
    def celsius(self):
        return self.__celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self.__celsius = value

    @property
    def fahrenheit(self):
        return self.__celsius * 9 / 5 + 32

t = Temperature(25)
print(t.celsius)      # calls the getter
print(t.fahrenheit)   # computed on the fly
t.celsius = 100       # calls the setter
print(t.fahrenheit)

Notice that fahrenheit has no setter — it is read-only. Trying to assign to it would raise an AttributeError. That is intentional: Fahrenheit is computed from Celsius, so letting someone set it directly would be confusing.

Tip

Start public, go private when needed

You do not need to hide everything from day one. Start with public attributes. When you find yourself needing to validate or compute a value, that is your signal to convert it to a @property. Python makes this refactor easy because callers never see the difference — obj.celsius looks the same whether it is a plain attribute or a property.

Common mistake

Forgetting the setter means read-only

If you define a @property getter but forget to add a @name.setter, the attribute is read-only. Trying to assign to it raises AttributeError: can't set attribute. This is often what you want — but when it catches you by surprise it can be confusing. Always check whether you need a setter.

#Putting It All Together

A realistic class combining all three access levels and @property.
class User:
    def __init__(self, username, age):
        self.username = username   # public
        self._role = "member"      # protected convention
        self.__age = age           # private, validated via property

    @property
    def age(self):
        return self.__age

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError("Age must be a non-negative integer")
        self.__age = value

    def profile(self):
        return f"{self.username} ({self.__age}) — {self._role}"

u = User("alice99", 28)
print(u.profile())
u.age = 29
print(u.age)
Quick check

What happens when you try to access `obj.__secret` from outside the class where `__secret` was defined?

Key takeaways

  • Encapsulation means bundling data with the methods that operate on it, and controlling access to keep internals safe.
  • Use no underscore for public, `_single` for protected (internal by convention), and `__double` for private (name-mangled).
  • Python has no true private — the underscore system is a respected social contract, not a hard lock.
  • Use `@property` to expose computed or validated attributes with clean attribute-style syntax.
  • Hiding internals protects invariants: rules (like 'balance >= 0') that must always hold are enforced in one place.
Practice challenges
Test yourself · earn XP
0/4
Predict the output#1

What does this code print?

predict-output
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance

    def deposit(self, amount):
        self.__balance += amount

    def get_balance(self):
        return self.__balance

acc = BankAccount("Alice", 100)
acc.deposit(50)
print(acc.get_balance())
Fix the bug#2

This code tries to print the temperature in Fahrenheit, but it crashes. What is wrong?

fix-bug
class Temperature:
    def __init__(self, celsius):
        self.__celsius = celsius

    @property
    def fahrenheit(self):
        return self.__celsius * 9 / 5 + 32

t = Temperature(100)
t.fahrenheit = 212
print(t.fahrenheit)
Fill in the blank#3

Complete the class so that reading u.age calls the getter and returns the private __age value.

class User:
    def __init__(self, age):
        self.__age = age

    @
    def age(self):
        return self.__age

u = User(25)
print(u.age)
Reorder the lines#4

Put these lines in the right order to define a Circle class with a private radius and a read-only area property, then print the area.

1
    def area(self):
2
    @property
3
    def __init__(self, radius):
4
        self.__radius = radius
5
print(c.area)
6
        return 3.14 * self.__radius ** 2
7
c = Circle(5)
8
class Circle:
Your turn
Practice exercise

Create a Rectangle class that stores width and height as private attributes. Use @property with setters to expose them, validating that each value is a positive number. Add a read-only area property that returns width * height. Test it by creating a rectangle, printing its area, updating one dimension, and printing the area again.

Try it live — edit the code and hit Run to execute real Python:

solution.py · editable