Encapsulation

Python signals what's internal to a class through naming conventions rather than compiler-enforced access modifiers, trading strict enforcement for flexibility and trust.

Encapsulation is the practice of bundling data with the methods that operate on it and controlling how that data is accessed from outside the class. Languages like Java or C++ enforce this with keywords such as private and protected that the compiler checks; Python instead relies on naming conventions that communicate intent and are honored by convention, backed by one real (but limited) mechanism called name mangling.

Public Attributes: the Default

Any attribute or method whose name does not start with an underscore is public: it is part of the class's intended external interface, freely readable and writable from outside the class.

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x = x  # public
        self.y = y  # public


p = Point(1, 2)
p.x = 10  # perfectly normal, expected usage

Single Underscore: _name (Protected by Convention)

A single leading underscore (_name) is a widely honored convention meaning 'internal -- not part of the public API, please don't touch this from outside the class or module.' Python enforces nothing: _name is exactly as accessible as name. Tools (linters, IDEs) and disciplined developers respect the convention; the interpreter does not.

from module import * also respects a leading underscore: names starting with _ at module level are excluded from a wildcard import unless the module defines __all__.

class Connection:
    def __init__(self, host: str) -> None:
        self.host = host
        self._socket = None  # internal implementation detail

    def _reconnect(self) -> None:  # internal helper method
        ...


c = Connection("example.com")
c._socket = "still accessible"  # allowed, but violates the convention

Double Underscore: __name and Name Mangling

A name with two leading underscores and at most one trailing underscore (__name) triggers name mangling: inside the class body, Python rewrites __name to _ClassName__name. This is a real, enforced mechanism -- not just convention -- but its purpose is to avoid accidental name collisions in inheritance hierarchies, not to provide security or true privacy.

Mangling happens at compile time based on the textual class body the code is written in, purely as source-level text substitution -- __secret inside class Base always becomes _Base__secret, regardless of runtime type.

class Base:
    def __init__(self) -> None:
        self.__secret = 42  # becomes self._Base__secret

    def reveal(self) -> int:
        return self.__secret  # also rewritten to self._Base__secret


b = Base()
print(b.reveal())        # 42
print(b._Base__secret)   # 42 -- still reachable via the mangled name
try:
    print(b.__secret)    # AttributeError -- no attribute literally named __secret
except AttributeError as e:
    print("caught:", e)

Why Name Mangling Exists: Avoiding Collisions in Subclasses

The real motivation for name mangling is to let a base class use a 'private' attribute name without a subclass accidentally overwriting it, and vice versa.

Without mangling, Derived's self.__id = 'derived' would silently overwrite Base's internal state; mangling keeps the two attributes distinct even though both were written as __id.

class Base:
    def __init__(self):
        self.__id = "base"   # becomes _Base__id

    def show(self):
        return self.__id     # becomes _Base__id


class Derived(Base):
    def __init__(self):
        super().__init__()
        self.__id = "derived"  # becomes _Derived__id -- does NOT clash with Base's


d = Derived()
print(d.show())         # 'base' -- Base.__id (mangled to _Base__id) is untouched
print(d._Derived__id)   # 'derived'

Public, Protected, Private: Terminology in Python

It's common to map Python's conventions onto the public/protected/private vocabulary from other languages, but the mapping is approximate, not enforced by the language the way it is in Java or C++.

'Private' in Python is really 'name-mangled to reduce accidental access and collisions', not 'inaccessible'. Anyone who knows the mangled name (_ClassName__name) can still read or write it. Python's philosophy here is often summarized as 'we're all consenting adults' -- the language trusts callers not to reach into internals rather than physically preventing it.

name     -> "public"     : no leading underscore, freely used externally
_name    -> "protected"  : convention only, still accessible; means "internal, tread carefully"
__name   -> "private"    : name-mangled, harder to reach by accident, still reachable deliberately

Convention Versus Enforcement

This is the central conceptual point of encapsulation in Python: naming conventions communicate *intent* to other programmers and to tools, while name mangling provides a *mechanical* (but easily bypassed) obstacle. Neither is a security boundary. If you need to prevent external code from ever reading a value, Python's object model cannot guarantee that -- the guarantee has to come from process boundaries, not from a class definition.

class BankAccount:
    def __init__(self, balance: float) -> None:
        self.__balance = balance

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

    @property
    def balance(self) -> float:
        return self.__balance


acct = BankAccount(100)
acct.deposit(50)
print(acct.balance)          # 150 -- intended interface
acct._BankAccount__balance = -999  # still possible; violates the intended contract

Encapsulation Through Properties

In practice, Python encapsulation is usually implemented by pairing a name-mangled or single-underscore backing attribute with a @property, giving controlled read/write access while keeping the raw storage internal (see the Properties topic for full detail on getters/setters).

class Thermostat:
    def __init__(self, target: float) -> None:
        self._target = target  # single underscore: internal storage

    @property
    def target(self) -> float:
        return self._target

    @target.setter
    def target(self, value: float) -> None:
        if not (5 <= value <= 35):
            raise ValueError("target out of safe range")
        self._target = value

Dunder Methods Are Not Name-Mangled

Name mangling only applies to identifiers with at most one trailing underscore (e.g. __x, not __x__). Special methods like __init__, __repr__, or __len__ are exempt precisely so the interpreter can find them by their exact, predictable name across every class.

class C:
    def __init__(self):
        pass  # NOT mangled -- Python needs to call this exact name

Module-Level Encapsulation

The same underscore convention applies outside classes, at module level: a name prefixed with _ signals 'internal to this module, not part of its public API', and is excluded by from module import * unless explicitly listed in __all__.

# mymodule.py
_internal_cache = {}

def _helper():
    ...

def public_api():
    return _helper()

Edge Cases

A single trailing underscore (name_) is a different convention entirely -- it avoids clashing with a Python keyword (class_, type_) and has nothing to do with encapsulation. Dunder names with a trailing underscore too (__name__) are reserved for the language's own use and also exempt from mangling.

def make(type_: str, class_: str) -> None:
    ...  # trailing underscore avoids shadowing the builtins/keywords 'type' and 'class'

Key Takeaways

Python has no compiler-enforced private or protected keywords; access control is convention-based.

A single leading underscore (_name) means 'internal, please don't touch' -- honored by convention, not enforced.

A double leading underscore (__name) triggers name mangling to _ClassName__name, mainly to avoid attribute collisions in inheritance, not to provide real privacy.

Mangled attributes remain accessible via their mangled name -- Python trusts programmers rather than blocking access outright.

Real encapsulation in Python is typically achieved by pairing an internal attribute with a @property that controls read/write access.

class C: def __init__(self): self.__x = 1 c = C() print(c.__x) # AttributeError class C: def __init__(self): self.__x = 1 c = C() print(c._C__x) # works, but signals you're reaching past the intended interface

__x inside class C is mangled to _C__x; accessing c.__x from outside looks for a literal attribute named __x, which doesn't exist.

class Base: def __init__(self): self.__count = 0 # intended to be shared/overridable state class Base: def __init__(self): self._count = 0 # single underscore, meant to be used/overridden by subclasses

Double-underscore attributes are mangled per-class, so a subclass cannot easily see or override Base's __count; use a single underscore when subclass access/override is intended.

Name mangling is trivially bypassed via obj._Secret__key; it is not a security mechanism and must never be relied on to protect sensitive data.

What does a single leading underscore on an attribute name signal in Python, and is it enforced?

It signals 'internal to the class/module, not part of the public API' by convention only; Python does not restrict access to it in any way.

What transformation does Python apply to an attribute named __value inside a class named Widget, and when does it happen?

It becomes _Widget__value. The rewrite (name mangling) happens at compile time, based purely on the textual class body the reference appears in.

What is the primary purpose of name mangling -- security or something else?

Avoiding accidental attribute name collisions between a base class and its subclasses, not providing security; mangled names remain fully accessible if you know the mangled form.

Why are dunder methods like __init__ and __repr__ not affected by name mangling?

Mangling only applies to names with at most one trailing underscore; names with two leading AND two trailing underscores (dunders) are exempt so the interpreter can locate them by a predictable, fixed name across all classes.

In a subclass that sets self.__id in its own __init__, does it overwrite a base class's self.__id set in its __init__? Why or why not?

No -- because each __id is mangled relative to the class it's written in (_Base__id vs _Derived__id), the two attributes are stored under different names and don't collide.

How does encapsulation in Python typically get implemented in practice, given the language provides no enforced access modifiers?

By storing state in a single- or double-underscore-prefixed attribute and exposing controlled access through a @property with a getter and, if writable, a validating setter.

Why is Python's approach to encapsulation sometimes summarized as 'we are all consenting adults'?

Because the language deliberately chooses not to hard-enforce access restrictions, trusting programmers to respect naming conventions rather than physically blocking access to internals the way private does in Java or C++.

What does this print, and why don't the two __v assignments collide?

1 2 -- name mangling rewrites self.__v to self._A__v inside A and to self._B__v inside B, so each class gets its own distinctly-named storage even though both source lines read self.__v.

Write a Password class that stores a password using name mangling (double underscore) and exposes only a check(candidate) method -- no direct getter for the raw password.

class Password: def __init__(self, raw: str) -> None: self.__raw = raw def check(self, candidate: str) -> bool: return candidate == self.__raw p = Password("hunter2") print(p.check("wrong")) # False print(p.check("hunter2")) # True

The password is stored as self.__raw, mangled to _Password__raw, with no property exposing it directly; only the check() method compares against it, keeping the raw value out of the public interface (though still technically reachable via _Password__raw, since Python has no true private state).

Which of _cache and __version appears unmangled versus mangled in Module's own __dict__ (as shown by vars(Module))?

_cache appears as '_cache' (single underscore is not mangled). __version appears as '_Module__version' (double underscore triggers mangling), since mangling rewrites the name textually wherever it's defined, including class-level attributes.

class A:
    def __init__(self):
        self.__v = 1

class B(A):
    def __init__(self):
        super().__init__()
        self.__v = 2

b = B()
print(b._A__v, b._B__v)
class Module:
    _cache = {}
    __version = "1.0"

print(list(vars(Module).keys()))