Intermediate Python 📂 Class and Object · 5 of 10 37 min read

Python OOP — Private Methods & Private Variables

Learn how Python handles encapsulation without a real private keyword. Master the single underscore convention (_protected), the double underscore name-mangling trick (__private), private helper methods, and how they combine to give you a safe public API with a hidden implementation. Includes a shared-house analogy, three animated SVG diagrams (access levels, name mangling, private helper flow), a full BankAccount PIN example, and 10 golden rules.

Section 01

The Story That Explains Private Members

The Shared House — Bulletin Board, Bedroom, Locked Safe
Imagine you live in a shared house with three flatmates. Three kinds of things live in this house:

The bulletin board in the hallway — pinned notes about the rubbish schedule, the WiFi password, the address. Anyone can read them and update them. These are public attributes.

Your bedroom door with a "please knock" sign — the door isn't locked. Your flatmates could walk in, but by convention they don't. If they enter anyway, it's their responsibility if something breaks. These are protected attributes (single underscore).

Your locked personal safe inside the bedroom — passport, bank cards, diary. The house rules actively rename the safe to make it hard to find by accident. You can technically still crack it open, but you've clearly gone out of your way. These are private attributes (double underscore, name-mangled).

Python doesn't have a hard private keyword. It uses naming conventions and one small piece of magic (name mangling) to signal intent: "public — help yourself," "protected — don't unless you know what you're doing," and "private — really, this is not for you."
🔑
The Core Insight

Python doesn't enforce privacy the way Java or C++ does — it signals it. A single underscore _x is a polite request. A double underscore __x triggers name mangling, which renames the attribute internally to make accidental access very unlikely. Together they let you build classes with a clear, safe public API and an internal implementation that outsiders can't casually touch.


Section 02

The Problem — Python Makes Everything Public by Default

When you write self.balance = 1000 in __init__, any caller can reach into that object and change account.balance = -1_000_000. Nothing stops them. That's fine for a script, but for a real class it's a disaster.

class BankAccount:
    def __init__(self, holder, balance):
        self.holder  = holder
        self.balance = balance         # public — anyone can touch it

acc = BankAccount("Alice", 1000)

# Nothing stops this — the whole class contract is gone
acc.balance = -99999999
print(acc.balance)   # -99999999   ← no validation, no history, no protection
⚠️
The Fix Isn't Enforcement — It's Convention

Python's philosophy is "we are all consenting adults." It gives you tools to signal what's part of your public API and what isn't. Callers who ignore those signals do so at their own risk. That's usually enough — because in real teams, code review catches anyone who tries to reach into a class's private innards.


Section 03

Single Underscore _protected — "Please Knock"

A name that starts with one underscore (self._log, self._retries) is a hint to every reader: "this is internal to the class. Don't touch it from outside unless you know what you're doing." Python itself does nothing special — no mangling, no access errors. It's a pure convention, universally respected in the community.

class BankAccount:
    def __init__(self, holder, balance):
        self.holder   = holder         # public — okay to read/write
        self._log     = []             # protected — internal use, but subclasses can extend
        self._version = 1              # protected — for internal bookkeeping

acc = BankAccount("Alice", 1000)

# You CAN still touch _log from outside — Python doesn't stop you...
print(acc._log)          # []   (works — but you shouldn't)

# ...but every Python developer reads "_log" as "don't touch, this is internal."
💡
When to Use Single Underscore

Use _name for attributes and methods that are internal but subclasses may still need them. Common examples: _log, _cache, _state, _normalize(). The single underscore says "not part of my public API, but not sealed off from my family either."


Section 04

Double Underscore __private — Name Mangling Locks the Safe

A name that starts with two underscores (and doesn't end in two) triggers Python's name mangling. Python quietly renames self.__balance behind the scenes to self._ClassName__balance. Anyone who tries the obvious account.__balance from outside gets an AttributeError.

class BankAccount:
    def __init__(self, holder, balance):
        self.holder    = holder
        self.__balance = balance       # private — will be renamed by Python
        self.__pin     = None          # private — you REALLY can't leak this

    def show(self):
        # Inside the class, __balance works as expected
        print(f"{self.holder}: ${self.__balance}")

acc = BankAccount("Alice", 1000)
acc.show()                       # Alice: $1000

# ── The obvious access from outside FAILS ──
try:
    print(acc.__balance)
except AttributeError as e:
    print("Blocked:", e)         # 'BankAccount' object has no attribute '__balance'

# ── What Python actually did: it renamed the attribute ──
print(acc.__dict__)              # {'holder': 'Alice', '_BankAccount__balance': 1000, '_BankAccount__pin': None}

# ── You COULD still reach it via the mangled name, but everyone knows you're cheating ──
print(acc._BankAccount__balance) # 1000 — works, but any reviewer will reject this
OUTPUT
Alice: $1000 Blocked: 'BankAccount' object has no attribute '__balance' {'holder': 'Alice', '_BankAccount__balance': 1000, '_BankAccount__pin': None} 1000
🔒
Name Mangling ≠ Real Security

Name mangling stops accidental access — the "oops, I typed acc.__balance" mistake. It does not stop determined attackers. Anyone who wants can still write acc._BankAccount__balance. Private in Python means "actively discouraged," not "cryptographically sealed." Never use it as a security boundary.


Section 05

Animated Diagram — Public vs Protected vs Private Access

Three attributes on one BankAccount. Watch what happens when outside code tries to reach each of them. Public wins instantly, protected works but with a "you shouldn't" warning, private crashes with an AttributeError.

THREE ACCESS LEVELS  ·  PUBLIC · PROTECTED · PRIVATE
acc = BankAccount(...) PUBLIC self.holder = "Alice" no prefix — open API PROTECTED self._log = [] one underscore — please knock PRIVATE self.__balance = 1000 two underscores — name-mangled stored as _BankAccount__balance ATTEMPTS FROM OUTSIDE acc.holder → 'Alice' works  ·  part of public API acc._log → [] works, but discouraged acc.__balance → AttributeError no attribute '__balance' name-mangled — blocked

Three attempts, three different outcomes. Public is open, protected is a signal, private is actually harder to reach because Python renamed it.


Section 06

Animated Diagram — How Name Mangling Works

When Python compiles the class body, any name starting with __ (and not ending in __) is silently rewritten. The rule is simple: prepend _ClassName. Inside the class, self.__balance and the mangled name behave identically. From outside, only the mangled name works.

NAME MANGLING  ·  PYTHON RENAMES __name TO _ClassName__name
WHAT YOU WRITE class BankAccount: def __init__(self): self.__balance = 1000 Python renames WHAT PYTHON STORES acc.__dict__ = { '_BankAccount__balance': 1000 } ACCESS ATTEMPTS FROM OUTSIDE acc.__balance → AttributeError (that name doesn't exist) acc._BankAccount__balance → 1000 (ugly, but works) Inside the class, self.__balance just works — Python mangles both sides identically. From outside, only the mangled name reaches the data — accidental typos of __balance fail loudly.

Name mangling is deterministic: __name in class C always becomes _C__name. Once you know the rule, no mystery — just an active nudge to leave it alone.

📋
One More Subtlety — __dunder__ Is Not Private

Names with double underscores on both ends — __init__, __str__, __len__ — are called dunders ("double underscore"). They are not mangled and are not private. Python reserves them for special protocol methods that you're expected to override. Only names that start with __ but don't end with __ get mangled.


Section 07

Private Methods — Helpers That Should Never Be Called From Outside

Everything you learned about private variables applies to private methods identically. A method whose name starts with __ gets name-mangled just like an attribute. Use this to hide implementation details — validation, formatting, internal setup — that callers should never care about.

class User:
    def __init__(self, username, password):
        self.username = username
        self.__password_hash = self.__hash(password)   # uses private method

    # ── Public API ─────────────────────────────────────
    def check_password(self, guess):
        return self.__password_hash == self.__hash(guess)

    # ── Private helper — never called from outside ─────
    def __hash(self, s):
        # In real code: hashlib.sha256(...). Toy here.
        return hex(hash(s))

alice = User("alice", "secret")
print(alice.check_password("secret"))    # True   (public method works)
print(alice.check_password("guess"))     # False

# Calling the private helper from outside → AttributeError
try:
    alice.__hash("anything")
except AttributeError as e:
    print("Blocked:", e)
OUTPUT
True False Blocked: 'User' object has no attribute '__hash'

Animated Diagram — Public API Calls Private Helpers

Outside code only ever calls the public method withdraw(500, pin). Behind that door, the method invokes several private helpers — __verify(), __log_txn() — that the caller never sees or knows about. That's encapsulation.

PUBLIC ENTRY POINT  ·  PRIVATE HELPERS RUN INSIDE
OUTSIDE CODE acc.withdraw(500, "1234") class BankAccount — inside PUBLIC withdraw(self, x, pin) PRIVATE __verify(pin) PRIVATE __check_balance(x) PRIVATE __log_txn("withdraw", x) outside can't call any private ↘

Callers see one clean entrance. Behind that door, private helpers do the real work — verify, check, log. The caller never sees them, never depends on them.


Section 08

Practical Example — BankAccount With Private Balance and PIN

A realistic small class that uses all three access levels together: a public API, protected logging that subclasses may extend, and truly private state (balance, PIN) that only the class itself touches through private helper methods.

class BankAccount:
    def __init__(self, holder, opening_balance, pin):
        if not BankAccount.__is_valid_pin(pin):
            raise ValueError("PIN must be 4 digits")
        self.holder    = holder            # public — the customer's name
        self._log      = []                # protected — subclasses may extend
        self.__balance = opening_balance   # private — the whole point of a bank
        self.__pin     = pin               # private — should NEVER leak

    # ══ PUBLIC API — the only entry points ═══════════════════
    def deposit(self, amount, pin):
        self.__verify(pin)
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self.__balance += amount
        self.__log_txn("deposit", amount)

    def withdraw(self, amount, pin):
        self.__verify(pin)
        if amount > self.__balance:
            raise ValueError("Insufficient funds")
        self.__balance -= amount
        self.__log_txn("withdraw", amount)

    def get_balance(self, pin):
        # Even reading the balance requires the PIN
        self.__verify(pin)
        return self.__balance

    # ══ PRIVATE HELPERS — hidden implementation details ══════
    def __verify(self, pin):
        if pin != self.__pin:
            raise PermissionError("Wrong PIN")

    def __log_txn(self, action, amount):
        self._log.append((action, amount))

    @staticmethod
    def __is_valid_pin(pin):
        return isinstance(pin, str) and len(pin) == 4 and pin.isdigit()


# ── Normal use — everything works through the public API ───
acc = BankAccount("Alice", opening_balance=1000, pin="1234")
acc.deposit(500, pin="1234")
acc.withdraw(200, pin="1234")
print("Balance:", acc.get_balance(pin="1234"))     # Balance: 1300
print("Log:    ", acc._log)                    # protected — visible but internal

# ── Attempts to bypass the API ─────────────────────────────
try: acc.withdraw(100, pin="9999")
except PermissionError as e: print("Blocked:", e)   # wrong PIN

try: print(acc.__balance)
except AttributeError as e: print("Blocked:", e)    # direct read fails

try: acc.__verify("9999")
except AttributeError as e: print("Blocked:", e)    # private helper is not callable
OUTPUT
Balance: 1300 Log: [('deposit', 500), ('withdraw', 200)] Blocked: Wrong PIN Blocked: 'BankAccount' object has no attribute '__balance' Blocked: 'BankAccount' object has no attribute '__verify'
🏆
What This Class Actually Guarantees

Nobody can set the balance directly — every mutation goes through deposit or withdraw, which validate. Nobody can read the balance without the PIN. Nobody can call __verify to fish for correct PINs. The class exposes a small, safe surface, and everything else is hidden behind name mangling. That's encapsulation in Python.


Section 09

Public API vs Private Implementation — The Contract

🔏 Private  ·  Implementation Details
Named with __x (double underscore prefix)
Python renames them → _ClassName__x
You are free to change these any time without warning
Callers must not depend on them
Examples: __balance, __pin, __hash()
📱 Public  ·  The API Contract
Named with no prefix (deposit, get_balance)
Callable from anywhere — that's the point
You promise not to break these without a version bump
Every caller safely depends on them
Change them → everyone downstream needs to update
🔑
Why This Split Matters

The whole point of marking members private is freedom. Once you've committed to a public method, breaking its signature means breaking every caller. But you're free to swap out the private implementation whenever — add caching, change algorithms, split a helper into three — as long as the public API keeps behaving the same way. Private members are the space where you can refactor without fear.


Section 10

Common Mistakes (and Fixes)

MistakeWhat Goes WrongFix
Reading a private attribute from outside via mangling acc._BankAccount__balance works, but ties your code to a class name Never do this. Add a public getter method to the class instead
Using __name when you mean "internal for subclasses too" Subclasses can't easily override or read it — name mangling isolates them Use _name (single underscore) — the convention that respects subclasses
Treating private as security Anyone can still read _ClassName__x — private is a signal, not a lock Don't store secrets in a Python attribute expecting cryptographic hiding
Naming things __init or __str Confusing — looks like a dunder to readers; still mangled Use _init_helper or __init_helper — clearly not a protocol name
obj.__attr = value from outside Creates a per-instance attribute called literally __attr (no mangling outside the class) Never write to double-underscore names from outside; they're not your API
Overusing double underscore everywhere Subclasses can't participate; mangled names clutter __dict__ Default to _x; reserve __x for genuinely private state
Marking everything private "just in case" You lose the ability to compose or extend the class; testing gets painful Start with a small public API. Make things private only when you have a reason

Section 11

Quick Reference

PrefixCalledWhat Python DoesWhen to Use
x public Nothing — fully accessible The class's official API
_x protected (convention) Nothing — pure signal to humans Internal, but subclasses may use
__x private (name-mangled) Renames to _ClassName__x Truly internal — no one else touches
__x__ dunder (magic) Nothing — reserved for protocol methods Only when implementing Python protocols
_x_ unusual — avoid Nothing special No convention — don't invent one
TaskSyntaxNotes
Declare private attributeself.__balance = 1000Only inside the class
Declare private methoddef __verify(self, pin):Called via self.__verify(...) internally
Inspect the mangled nameobj.__dict__Shows _ClassName__x keys
Access a private (don't do it)obj._ClassName__xCheating — reject in code review
Provide a public getterdef get_x(self): return self.__xThe right way to expose a private
Modern getter/setter@property decoratorPreferred over explicit get/set methods

Section 12

Golden Rules

🔒 Private Variables & Private Methods — Non-Negotiable Rules
1
Python has no real private keyword. It has naming conventions and name mangling. Both are signals, not security. Anyone determined enough can still reach in.
2
Single underscore _x is a polite convention — "internal, don't touch unless you know what you're doing." Python does not enforce it. Every Python developer respects it.
3
Double underscore __x triggers name mangling: Python renames it to _ClassName__x. Accidental access from outside fails with AttributeError. Deliberate access still works, but shouts "I am cheating."
4
Names with double underscore on both sides (__init__, __str__) are not private. They're Python's protocol/dunder names — reserved for special behaviour. Never invent your own __myname__.
5
Default to _x. Reach for __x only when you have a specific reason — usually sensitive state (a PIN, a token) or something that must not clash with a subclass's attribute of the same name.
6
Every class has a public API (a small set of methods you promise to keep working) and a private implementation (everything else — free to change whenever). The whole point of private is to reserve refactoring room.
7
When a caller needs to read or write a private attribute, add a public getter/setter method — ideally via @property. Never expose the mangled name as an "escape hatch."
8
Private methods are for helpers no one outside your class should call — validators (__verify), formatters (__hash), internal setup (__log_txn). Keep them small and focused; they're the ingredients of your public methods.
9
Never rely on private as a security boundary. Passwords, secret keys, tokens deserve real cryptographic protection — hashing, encryption, secure vaults — not just __password.
10
When in doubt, ask: "Can I break this next month without hurting anyone?" If yes → private (__x). If subclasses might legitimately touch it → protected (_x). If callers depend on it → public. Match the naming to your promise.