Python Encapsulation

Encapsulation is the object-oriented practice of bundling data (attributes) and the methods that operate on that data inside a single unit — a class — while controlling how the outside world is allowed to access or change that data. Instead of letting any part of a program freely reach in and mutate an object’s internal state, encapsulation hides implementation details behind a well-defined interface, so the object itself can enforce the rules about what values are valid. In Python, encapsulation is achieved through naming conventions and language mechanisms like name mangling and properties, rather than compiler-enforced access modifiers such as private or public keywords in Java or C++. That makes it especially important to understand how it really works instead of assuming it behaves like those languages.

Overview: How Encapsulation Works in Python

Every Python object stores its attributes in a dictionary-like namespace (usually accessible via vars(obj) or obj.__dict__). By default, absolutely nothing stops external code from reading or writing any attribute on that object — Python’s design philosophy, often summarized as "we’re all consenting adults here," trusts programmers to respect conventions rather than relying on the interpreter to police access. Because of this, Python encapsulation works on three levels of increasing (but never absolute) restriction:

  • Public — a normal name like self.balance. Freely readable and writable from anywhere. This is the default and should be used for attributes that are genuinely part of the object’s public interface.
  • Protected (by convention) — a name with a single leading underscore, like self._balance. This is a signal to other developers meaning "this is an implementation detail; don’t touch it from outside the class or its subclasses," but Python does not enforce this in any way. It is purely a documentation convention, though tools like linters and IDEs will often flag external access to it.
  • Private (name-mangled) — a name with two leading underscores and at most one trailing underscore, like self.__balance. Python’s compiler actually rewrites this name at parse time inside the class body, turning every occurrence of __balance into _ClassName__balance. This is called name mangling, and it is the closest thing Python has to real private attributes — but it is still just a renaming trick, not true access control, since the mangled name is easy to discover and use directly.

On top of these naming conventions, Python provides the @property decorator, which lets a class expose what looks like a simple public attribute while actually running code (validation, computation, logging) whenever that attribute is read, written, or deleted. This is the idiomatic Python way to enforce invariants on an object’s data without forcing every caller to use verbose get_x()/set_x() methods.

Syntax

class ClassName:
    def __init__(self, value):
        self.public_attr = value        # no leading underscore
        self._protected_attr = value     # single leading underscore (convention)
        self.__private_attr = value      # double leading underscore (name-mangled)

    @property
    def some_value(self):
        return self.__private_attr

    @some_value.setter
    def some_value(self, new_value):
        if new_value < 0:
            raise ValueError("value must be non-negative")
        self.__private_attr = new_value
Convention Example Meaning
Public self.name Part of the public API; access freely
Protected self._name Internal use only, by convention; not enforced
Private self.__name Name-mangled to _ClassName__name; discourages accidental access and subclass clashes
Dunder self.__name__ Reserved for Python’s own special methods/attributes; not mangled because it has a trailing double underscore too

Examples

Example 1: Public vs. Protected Attributes

class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner = owner          # public attribute
        self._balance = balance     # "protected" by convention

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

    def get_balance(self) -> float:
        return self._balance


account = BankAccount("Maria", 100.0)
account.deposit(50.0)
print(f"{account.owner}'s balance: {account.get_balance()}")

# Nothing stops direct access, but the underscore signals "internal use only"
account._balance -= 1000  # discouraged, but not blocked
print(f"After risky direct edit: {account.get_balance()}")

Output:

Maria's balance: 150.0
After risky direct edit: -850.0

The single underscore on _balance does nothing to stop account._balance -= 1000 from executing — it runs without error and silently corrupts the account. That’s the point: a single underscore is a courtesy signal between developers, not a lock.

Example 2: Name Mangling with Double Underscores

class Account:
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner = owner
        self.__balance = balance  # mangled to _Account__balance

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

    def get_balance(self) -> float:
        return self.__balance


acc = Account("Sam", 200.0)
acc.deposit(25.0)
print(acc.get_balance())

try:
    print(acc.__balance)
except AttributeError as e:
    print(f"AttributeError: {e}")

print(acc._Account__balance)
print(vars(acc))

Output:

225.0
AttributeError: 'Account' object has no attribute '__balance'
225.0
{'owner': 'Sam', '_Account__balance': 225.0}

Even though __balance looks private, it still exists — just under the mangled name _Account__balance, visible in vars(acc). Anyone who knows the mangling rule can still reach it directly. Name mangling mainly protects against accidental name clashes in inheritance hierarchies, not against deliberate access.

Example 3: Enforcing Validation with @property

class Temperature:
    def __init__(self, celsius: float):
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is not physically possible")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32


temp = Temperature(25.0)
print(f"{temp.celsius}C = {temp.fahrenheit}F")

temp.celsius = 100.0
print(f"{temp.celsius}C = {temp.fahrenheit}F")

try:
    temp.celsius = -300.0
except ValueError as e:
    print(f"ValueError: {e}")

Output:

25.0C = 77.0F
100.0C = 212.0F
ValueError: Temperature below absolute zero is not physically possible

temp.celsius = 100.0 looks like plain attribute assignment, but it actually calls the celsius.setter method, which validates the value before storing it in self._celsius. This gives callers a clean, attribute-like interface while the class retains full control over its own invariants — the essence of encapsulation done the Pythonic way.

Under the Hood: What Python Actually Does

Name mangling happens purely as a lexical, compile-time text substitution, not a runtime check based on where the code is called from. When the Python compiler processes the body of a class statement, any identifier that starts with two or more underscores and ends with at most one underscore (like __balance, but not __init__, which has a trailing double underscore) is rewritten to _ClassName__identifier everywhere it appears inside that class body. This happens before the class even runs, so self.__balance = balance inside Account.__init__ is compiled as if you had written self._Account__balance = balance. That’s why the mangled name shows up in vars(acc) and why accessing acc.__balance from outside the class raises AttributeError — that exact name was never actually created.

@property works through a completely different mechanism: the descriptor protocol. Decorating a method with @property wraps it in a property object, which is a descriptor implementing __get__, __set__, and __delete__. Descriptors live on the class, not the instance, and Python’s attribute lookup machinery checks the class for a data descriptor before it ever looks at the instance’s own __dict__. That’s why temp.celsius triggers your getter method instead of returning a plain stored value, and why temp.celsius = -300.0 runs your validation logic instead of just overwriting a dictionary entry.

Common Mistakes

Mistake 1: Assuming a leading underscore enforces privacy. Many newcomers write self._balance and then are surprised that account._balance = -9999 works without any error. A single underscore is a convention read by humans and linters, not by the interpreter. If you need real enforcement of a rule (like "balance can never go negative"), you must use a @property setter that validates the value, as shown in Example 3.

Mistake 2: Not realizing name mangling can silently break inheritance. Because mangling is based on the class where the attribute is written, a subclass that reuses a double-underscore name doesn’t override the parent’s attribute — it creates a completely separate one.

class Base:
    def __init__(self):
        self.__value = 10  # becomes _Base__value

    def show(self):
        print(self.__value)  # always reads _Base__value


class Child(Base):
    def __init__(self):
        super().__init__()
        self.__value = 20  # becomes _Child__value, a DIFFERENT attribute

    def show_child(self):
        print(self.__value)  # reads _Child__value


c = Child()
c.show()
c.show_child()

Output:

10
20

Both attributes exist side by side inside the same instance because they were mangled against different class names. This surprises many developers, and it’s exactly why name mangling exists: it’s meant to prevent this kind of accidental clash from causing silent bugs when a subclass happens to choose the same attribute name for something unrelated. Use it deliberately, not by default on every attribute.

Best Practices

  • Start with plain public attributes. Only introduce a leading underscore, and later a @property, when you actually need to hide an implementation detail or enforce a rule — don’t add ceremony prematurely.
  • Use a single leading underscore (_name) for attributes and helper methods that are internal to the class or module but don’t need name-mangling protection.
  • Reserve double leading underscores (__name) for cases where you specifically want to avoid accidental overriding in subclasses, not as a general-purpose "make this private" switch.
  • Prefer @property and its .setter/.deleter over Java-style get_x()/set_x() methods — it keeps the calling code reading like simple attribute access while still letting you add validation later without breaking any caller.
  • Document the intended access level of protected attributes in docstrings, since Python itself won’t enforce or advertise it at runtime.
  • If you need to prevent arbitrary new attributes from being added to instances at all, look into __slots__, a related but distinct mechanism from name mangling.
  • Never rely on name mangling as a security boundary — it only guards against accidental name collisions, not determined or malicious access.

Practice Exercises

Exercise 1: Write a Person class with a private __age attribute. Expose it through an age property whose setter raises ValueError if the new value is negative. Verify that setting person.age = -5 raises the error while person.age = 30 works.

Exercise 2: Using the Base/Child example from the Common Mistakes section, predict what c.show() and c.show_child() would print if Child.__init__ did not call super().__init__(). Then reason about why.

Exercise 3: Refactor a class that has explicit get_email() and set_email(value) methods (where set_email should reject strings without an @ character) into a class that instead uses a @property named email with the same validation behavior.

Summary

  • Encapsulation bundles data and behavior into a class and controls how outside code can access that data.
  • Python has no true private keyword; it relies on naming conventions: public, _protected, and __private.
  • Double-underscore names undergo name mangling at compile time, becoming _ClassName__name — this mainly guards against accidental clashes in subclasses, not true security.
  • The @property decorator, backed by the descriptor protocol, is the idiomatic way to add validation or computed behavior while keeping a clean, attribute-like interface.
  • Because nothing in Python is truly locked down, encapsulation is ultimately a discipline the whole codebase agrees to follow, enforced by conventions, code review, and tools like linters rather than the interpreter itself.