SythraOpen app

Pillar One: Encapsulation in Python

Encapsulation means bundling an object's data and the methods that operate on it together, and protecting that data from invalid changes — in Python, typically using an underscore-prefixed attribute plus @property getters and setters that validate every read and write.

Sythra

11 min read

XLinkedIn
Pillar One: Encapsulation in Python — cover illustration

Remember the coffee machine story from The Four Pillars of OOP? Encapsulation was the idea that everything related to "making coffee" — the water, the beans, the heating element, and actions like "boil" and "pour" — lives bundled together inside one single object.

You've actually already been practicing encapsulation every time you wrote a class. Every class you've built bundled its data (attributes) together with the functions that work on that data (methods). That bundling is the first half of encapsulation.

But there's a second half: protecting that bundled data from being changed carelessly, by accident, from outside the class. This article covers both halves.

What you will learn

  • Why bundling data and behavior together (the first half) genuinely helps
  • How Python's default behavior lets any code change an object's attributes without restriction
  • The underscore convention — a polite "please don't touch"
  • Getters and setters, and the cleaner @property decorator
  • A full worked example: a bank account that can never go negative

The bundling half: data and behavior, together

You already have the Time class from Classes and Methods__init__, __str__, time_to_int, all bundled inside one class. This bundling is genuinely valuable: everything related to a "Time" is in one place. If you need to understand how Time objects work, you look in exactly one spot, instead of hunting for loose functions scattered around your program.

Compare this to how you might write it without a class:

# Without encapsulation — data and behavior, scattered apart
def make_time(hour, minute, second):
    return {'hour': hour, 'minute': minute, 'second': second}

def time_to_int(t):
    minutes = t['hour'] * 60 + t['minute']
    seconds = minutes * 60 + t['second']
    return seconds

This works, but the data (a plain dictionary) and the functions that operate on it are completely disconnected. Nothing groups them together as "things that belong to Time." As your program grows to hundreds of functions, this becomes a genuine mess. The class version keeps everything "about Time" living inside Time, where it belongs.

The protection half: why bundling alone isn't enough

In Python, by default, any code anywhere can reach directly into an object and change its attributes, with zero restrictions:

t = Time(14, 30, 0)
t.hour = 99       # Python allows this without complaint
t.minute = -500   # Python allows this too!
print(t)
# → 99:-500:00   ← This is nonsense! There's no such time.

Nothing stopped this. Time is supposed to represent a sensible time of day — but Python happily let someone turn it into garbage. This is exactly the problem encapsulation's second half solves: protecting an object's internal data from being set to invalid, broken, or nonsensical values.

You can press the coffee machine's "Brew" button — the intended, safe way to interact with it. But you can't reach inside and rewire the heating element with your bare hands. The machine's outer shell protects its sensitive internals. That's the spirit we want for our classes too.

The underscore convention

Python doesn't have a hard, unbreakable way to truly lock attributes away. Instead it relies on convention. If an attribute name starts with a single underscore, it's a polite signal meaning "this is for internal use only — please don't touch this directly from outside the class":

class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self._hour = hour
        self._minute = minute
        self._second = second

Technically, t._hour = 99 still works — Python won't stop you. But the underscore is a universally-understood message to any other programmer (including future-you) that says: "I'm trusting you not to mess with this directly."

There's a stronger version — a double underscore prefix, which makes Python rename the attribute behind the scenes:

class Account:
    def __init__(self, balance):
        self.__balance = balance     # double underscore

acc = Account(100)
print(acc.__balance)
# → AttributeError: 'Account' object has no attribute '__balance'

Python actually renamed __balance internally to _Account__balance, specifically to make accidental outside access much less likely — a trick called name mangling. You'll rarely need this level of protection as a beginner, but it's worth knowing it exists.

Getters and setters: the proper front door

If we're hiding direct access to _hour, we need a proper, safe way to read and change it instead — methods traditionally called getters and setters:

class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self._hour = hour

    def get_hour(self):
        return self._hour

    def set_hour(self, hour):
        if 0 <= hour < 24:
            self._hour = hour
        else:
            raise ValueError("Hour must be between 0 and 23")
t = Time(14, 30, 0)
print(t.get_hour())    # 14

t.set_hour(99)
# → ValueError: Hour must be between 0 and 23

This is the real payoff of encapsulation's protective half. The setter acts like a security guard standing at the door — it checks every value before it's allowed in, and rejects anything that would put the object into a broken state.

A more Pythonic way: @property

Writing get_hour() and set_hour() works, but it's clunky — you must remember parentheses every time. Python's @property decorator lets you write getter and setter methods, but have them behave like plain attributes when used, with no parentheses at all:

class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self._hour = hour
        self.minute = minute
        self.second = second

    @property
    def hour(self):
        """Getter — runs when you READ t.hour"""
        return self._hour

    @hour.setter
    def hour(self, value):
        """Setter — runs when you ASSIGN t.hour = something"""
        if 0 <= value < 24:
            self._hour = value
        else:
            raise ValueError("Hour must be between 0 and 23")

Now look how clean this is to use:

t = Time(14, 30, 0)
print(t.hour)     # 14 — looks like reading a plain attribute, but it's calling the getter!

t.hour = 10       # looks like a plain assignment, but it's calling the setter!
print(t.hour)     # 10

t.hour = 99       # the setter quietly catches this
# → ValueError: Hour must be between 0 and 23

From the outside, t.hour looks and feels like a plain attribute. But underneath, every read and write is secretly checked by your getter and setter. You get the safety of getters/setters with the clean syntax of plain attributes — this is the standard, professional way encapsulation is done in real Python code.

A full worked example: a bank account

Let's see encapsulation do real, meaningful work — a bank account that must never go negative:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance   # protected — shouldn't be set directly

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

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

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount

Notice there's no setter for balance at all — only a getter. This is intentional: we never want someone to just set the balance directly. The only sanctioned way to change it is through deposit() and withdraw(), both of which validate first:

acc = BankAccount("Asha", 100)
acc.deposit(50)
print(acc.balance)     # 150

acc.withdraw(1000)
# → ValueError: Insufficient funds

acc.balance = 999999
# → AttributeError: can't set attribute

That very last line is the entire point of this example. There is genuinely no way to cheat this bank account into having a fake balance — every path that changes _balance goes through a method that validates first.

Common mistakes

  • Wrapping every single attribute with elaborate validation, even when it has no real constraints — this adds ceremony, not clarity
  • Forgetting that a single-underscore prefix is only a convention — Python still allows direct access
  • Providing a setter for something that should never be settable directly (like a bank balance), instead of only exposing a getter
  • Confusing this "data protection" sense of encapsulation with the narrower "data encapsulation" technique of moving global variables into a class

Common questions

What is encapsulation in Python?

Encapsulation is bundling an object's data (attributes) together with the methods that operate on that data inside a single class, and restricting direct outside access to that data so it can't be set to invalid values.

What does a single underscore before an attribute mean in Python?

A single underscore prefix, like _hour, is a naming convention signaling that the attribute is intended for internal use only. Python does not actually enforce this — it's a polite request to other programmers, not a hard restriction.

What is name mangling in Python?

Name mangling happens when you prefix an attribute with a double underscore, like __balance. Python internally renames it to _ClassName__balance, making it much harder (though not impossible) to access accidentally from outside the class.

What does the @property decorator do?

@property lets you define a method that behaves like a plain attribute when read. Paired with @name.setter, you can also validate values on assignment — all while callers use ordinary dot syntax (obj.attr) instead of calling methods with parentheses.

Why shouldn't every attribute have a getter and setter?

If an attribute has no real constraints — any value is valid — wrapping it in a property adds unnecessary ceremony. Reserve properties for attributes with genuine rules to enforce, like a balance that must stay non-negative.

Explore

Related topics

Keep going — these sit next to this concept in a real learning path.

Browse all python explainers →