Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

Encapsulation and Magic/Dunder Methods

Python's convention-based privacy, and the dunder methods that make your objects behave like built-in types.

40 min August 2026
// Part 01 — Python Has No Real Private

Underscore Conventions, Not Enforced Privacy

Languages like Java or C++ have a private keyword the compiler actively enforces. Python has no such thing — instead it uses naming conventions that every Python developer is expected to respect, backed by one small piece of real language behaviour for the double-underscore case.

The three levels, by convention
class Account:
    def __init__(self, balance):
        self.balance = balance        # public — freely accessed from outside
        self._pin = "1234"            # "protected" — a hint: internal use, please don't touch
        self.__secret_key = "xyz789"  # "private" — name-mangled, harder to access accidentally

acc = Account(500)
print(acc.balance)     # 500 — totally fine, it's public
print(acc._pin)        # "1234" — WORKS. Python does not stop you. It's a convention, not a lock.
print(acc.__secret_key)  # AttributeError! (see Part 02 — this one actually does something)

A single leading underscore (_pin) is a pure convention meaning "this is an internal implementation detail — you can access it, but you are opting out of any stability guarantee if you do." Nothing in the language prevents access; it is a signal to other engineers reading the code, identical in spirit to a comment that says "don't touch this."

// Part 02 — Name Mangling

What the Double Underscore Actually Does

A double leading underscore (__secret_key) is the one case where Python does something real: it renames the attribute internally to _ClassName__attribute — a mechanism called name mangling. This is not designed as a privacy mechanism at all; it exists to prevent accidental name collisions in subclasses, but it has the side effect of making the attribute genuinely awkward to reach from outside.

What's actually happening under the hood
class Account:
    def __init__(self):
        self.__secret_key = "xyz789"

acc = Account()
print(acc.__dict__)
# {'_Account__secret_key': 'xyz789'}  <- the REAL attribute name, mangled

print(acc.__secret_key)          # AttributeError — this name doesn't exist
print(acc._Account__secret_key)  # "xyz789" — still fully accessible if you know the mangled name
⚠️ Important
Name mangling is not security — it is collision avoidance. Anyone who knows the mangled name (_ClassName__attr) can still read or write it directly. Never use double underscores expecting to hide sensitive data (like real secrets or credentials) from a determined caller — that is not what the mechanism is for. Use it when you specifically want to avoid a subclass accidentally overwriting a base class's internal attribute of the same name.

In practice, most real Python code uses a single underscore for "internal, please don't touch" and reserves double underscores for the narrower collision-avoidance case, or skips them entirely in favour of clear naming and documentation.

// Part 03 — __str__ and __repr__

Controlling How an Object Prints

By default, printing a custom object gives you something genuinely unhelpful: <__main__.Product object at 0x7f8a1c0a5d90>. Two dunder methods let you control this — and they answer two different questions.

Without either method — the unhelpful default
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

p = Product("Keyboard", 79.99)
print(p)     # <__main__.Product object at 0x7f8a1c0a5d90>
__str__ — a readable, user-facing description
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __str__(self):
        return f"{self.name} (${self.price:.2f})"

p = Product("Keyboard", 79.99)
print(p)          # Keyboard ($79.99)  <- calls __str__
print(f"{p}")     # Keyboard ($79.99)  <- f-strings also call __str__
__repr__ — an unambiguous, developer-facing description
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __repr__(self):
        return f"Product(name={self.name!r}, price={self.price!r})"

p = Product("Keyboard", 79.99)
p                 # Product(name='Keyboard', price=79.99)   <- shown at the REPL / in a debugger
repr(p)           # "Product(name='Keyboard', price=79.99)"  <- Python switches to double quotes here, since the string itself contains a single quote
print([p])        # [Product(name='Keyboard', price=79.99)] <- lists print elements' repr, not str!

The rule of thumb every experienced Python engineer follows: __repr__ should ideally be valid Python code that could recreate the object — useful for debugging and logging — while __str__ is for a human-readable display. If you define only one, define __repr__: Python automatically falls back to it for __str__ if __str__ is missing, but not the other way around.

// Part 04 — __eq__ and __hash__

Defining What "Equal" Means for Your Own Objects

By default, == on two custom objects checks identity — are they literally the same object in memory — exactly like is. That is very often not what you want when comparing two objects that represent the same logical value.

The default — identity comparison, probably not what you want
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)     # False! Different objects in memory, even though the data is identical
Defining __eq__ — comparing by value
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)     # True — now comparing by value
⚠️ Important
Defining __eq__ silently disables the default __hash__, making instances unhashable. If you rely on putting instances of the class in a set or using them as dict keys, you must also define __hash__ explicitly — and it must be consistent with __eq__: two objects that compare equal must produce the same hash. Violating this contract causes objects to mysteriously "vanish" from sets or dicts, since the hash is used to locate the bucket before __eq__ is even consulted.
Adding a consistent __hash__
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    def __hash__(self):
        return hash((self.x, self.y))   # same inputs -> same hash, matching __eq__'s logic

points = {Point(1, 2), Point(1, 2)}
print(len(points))     # 1 — correctly recognised as the same logical value

A closely related trap: mutable objects generally should not define __hash__ at all (or should raise from it), because if an object's hash can change after it is placed in a set or used as a dict key, it becomes unfindable in its own bucket — the set/dict's internal structure assumes an object's hash never changes while it lives inside it.

// Part 05 — Ordering and Arithmetic Dunders

__lt__, total_ordering, and Operator Overloading

The same pattern extends to ordering (<, <=, etc.) and arithmetic (+, -, etc.) — each operator has a corresponding dunder method Python calls behind the scenes.

__lt__ enables sorting your own objects
class Employee:
    def __init__(self, name, salary):
        self.name, self.salary = name, salary

    def __lt__(self, other):
        return self.salary < other.salary

    def __repr__(self):
        return f"Employee({self.name!r}, {self.salary})"

team = [Employee("Cara", 95000), Employee("Ben", 82000)]
print(sorted(team))
# [Employee('Ben', 82000), Employee('Cara', 95000)]  -- sorted() uses __lt__ automatically
functools.total_ordering — deriving the rest from just __eq__ and __lt__
from functools import total_ordering

@total_ordering
class Employee:
    def __init__(self, name, salary):
        self.name, self.salary = name, salary

    def __eq__(self, other):
        return self.salary == other.salary

    def __lt__(self, other):
        return self.salary < other.salary

# total_ordering fills in __le__, __gt__, __ge__ automatically from just these two
Operator overloading with __add__
class Money:
    def __init__(self, cents):
        self.cents = cents

    def __add__(self, other):
        return Money(self.cents + other.cents)

    def __repr__(self):
        return f"${self.cents / 100:.2f}"

total = Money(500) + Money(250)
print(total)     # $7.50 — the + operator now works on Money objects directly

__len__ and __getitem__ — acting like a built-in collection

Making a custom class support len() and indexing
class Playlist:
    def __init__(self, songs):
        self._songs = songs

    def __len__(self):
        return len(self._songs)

    def __getitem__(self, index):
        return self._songs[index]

pl = Playlist(["Intro", "Solo", "Outro"])
print(len(pl))       # 3           -- len() calls __len__
print(pl[1])         # "Solo"      -- indexing calls __getitem__
for song in pl:      # __getitem__ alone is even enough to make an object iterable!
    print(song)
// Part 06 — Real World
💼 What This Looks Like at Work

A Vanishing-Duplicates Bug at a Denver Logistics Company

Scenario — Logistics company, Denver · Production bug report

A team defines a Shipment class with a hand-written __eq__ (comparing by tracking number) but never gets around to adding __hash__. Their code works fine for months — until someone starts collecting shipments into a set() to de-duplicate a batch import, and duplicate shipments start silently slipping through instead of being removed.

The bug
class Shipment:
    def __init__(self, tracking_number):
        self.tracking_number = tracking_number

    def __eq__(self, other):
        return self.tracking_number == other.tracking_number

    # No __hash__ defined!

s1 = Shipment("TRK001")
s2 = Shipment("TRK001")
print(s1 == s2)           # True — looks correct

unique = {s1, s2}
print(len(unique))        # 2, NOT 1! Duplicates were not removed.

Why this happens

Defining __eq__ without __hash__ does not raise an error — it silently falls back to identity-based hashing (the default from object), which is completely inconsistent with the value-based __eq__ just written. The set uses the (identity) hash to decide which bucket to check first, finds no collision because s1 and s2 hash differently, and never even calls __eq__ to compare them.

The fix
class Shipment:
    def __init__(self, tracking_number):
        self.tracking_number = tracking_number

    def __eq__(self, other):
        return self.tracking_number == other.tracking_number

    def __hash__(self):
        return hash(self.tracking_number)   # consistent with __eq__

unique = {Shipment("TRK001"), Shipment("TRK001")}
print(len(unique))        # 1 — correct now

The lesson the team took away: __eq__ and __hash__ are a matched pair — if you override one for value comparison, you almost always need to override the other, and the bug they produce when mismatched is exactly this kind of silent, hard-to-notice data corruption rather than a loud crash.

// Part 07 — Misconceptions

Four Misconceptions About Encapsulation and Dunders

✕ ""A double underscore (__attr) makes an attribute truly private, like in Java""
It triggers name mangling (renaming to _ClassName__attr), which prevents ACCIDENTAL access and subclass collisions — but anyone who knows the mangled name can still read or write it directly. It is not an enforced access-control mechanism.
✕ ""str() and repr() do basically the same thing, so it doesn't matter which you define""
They answer different questions — __str__ is for a human-readable display (what print() shows), __repr__ is for an unambiguous, debugging-oriented representation (what the REPL and lists of objects show). If you define only one, define __repr__, since Python falls back to it for __str__ automatically.
✕ ""If I define __eq__, equality and hashing will just work correctly together""
Defining __eq__ SILENTLY disables the inherited default __hash__, making instances unhashable unless you also define __hash__ yourself — and it must be consistent with __eq__, or objects will "vanish" from sets and dicts in exactly the way shown in the Real World example above.
✕ ""Operator overloading (__add__, etc.) is a rarely-used, advanced-only feature""
It is genuinely common in everyday Python — libraries like pandas, NumPy, and datetime all rely on it heavily (e.g. date2 - date1 returning a timedelta). It becomes a real tool, not just trivia, the moment you write a class that represents a value with natural arithmetic, like money or a vector.
// Part 08 — Interview Prep

5 Interview Questions — With Complete Answers

What is name mangling, and what problem does it actually solve?
A double-leading-underscore attribute (__attr) is renamed internally to _ClassName__attr. It is designed to prevent naming collisions between a base class's internal attributes and a subclass's own attributes of the same name — not as a privacy/security mechanism, since the mangled name is still fully accessible if you know it.
What is the difference between __str__ and __repr__?
__str__ produces a human-readable description, used by print() and f-strings. __repr__ produces an unambiguous, ideally code-like description, used by the REPL, debuggers, and containers like lists when displaying their elements. Defining only __repr__ is a safe default since Python falls back to it for __str__ automatically.
Why does defining __eq__ without __hash__ cause objects to behave incorrectly in sets and dicts?
Defining __eq__ silently removes the inherited default __hash__, making the class unhashable, UNLESS __hash__ is explicitly defined too — and it must be consistent with __eq__ (equal objects must hash equally). If it is inconsistent, objects can be placed in the wrong hash bucket and effectively "disappear" from lookups, since __eq__ is only checked within a bucket the hash already pointed to.
What does functools.total_ordering do?
Given a class that defines __eq__ and just one other comparison method (commonly __lt__), it automatically fills in the remaining comparison methods (__le__, __gt__, __ge__) so you don't have to hand-write all four/five yourself.
What is the minimum needed to make a custom object support both len() and iteration?
Implementing __len__ supports len(obj). Implementing __getitem__ (accepting integer indices starting from 0, and raising IndexError once exhausted) is actually enough on its own to make an object iterable in a for loop, even without a dedicated __iter__ method — Python falls back to repeatedly calling __getitem__ with increasing indices.
// Common Mistakes

Encapsulation & Dunder Mistakes Beginners Make Constantly

Defining __eq__ and expecting the object to still be hashable
It silently becomes unhashable (or inconsistently hashable) unless __hash__ is explicitly defined alongside it — see the Real World example above for exactly how this bites in production.
Assuming __attr (double underscore) is a security mechanism
It is collision-avoidance via name mangling, not privacy enforcement. The mangled name (_ClassName__attr) is still fully readable and writable from outside the class.
Forgetting the isinstance() check inside __eq__
Comparing self.x == other.x without first checking that "other" is even the right type raises an AttributeError the moment someone compares your object to something unrelated, like None or an int, instead of cleanly returning False (via NotImplemented, which Python then falls back on correctly).
Defining __repr__ using str() interpolation instead of !r for the fields
Interpolating with plain str() produces something like Product(Keyboard) — ambiguous, not valid Python. Using !r (Product(name={self.name!r})) produces a quoted, code-like value instead — unambiguous and closer to code that could recreate the object, which is the whole point of __repr__.
// Error Library

Errors You Will Hit With Encapsulation & Dunders — And Exactly Why

AttributeError: 'Product' object has no attribute '__secret_key'
Cause: Attempting to access a name-mangled double-underscore attribute directly from outside the class using its original, un-mangled name.
Fix: Access it through the class's own methods, or if you genuinely need external access, use the mangled name obj._ClassName__attr (a strong sign the attribute should not have been double-underscored in the first place).
TypeError: unhashable type: 'Shipment'
Cause: A class defines __eq__ without also defining __hash__, so Python sets __hash__ to None automatically, making instances unusable as set elements or dict keys.
Fix: Add a __hash__ method that is consistent with __eq__ — typically hash()ing the same fields __eq__ compares.
TypeError: '<' not supported between instances of 'Employee' and 'Employee'
Cause: Calling sorted() or using < on objects of a class that has not defined __lt__.
Fix: Define __lt__ (and consider @functools.total_ordering to get the rest of the comparison operators for free).
AttributeError: 'NoneType' object has no attribute 'tracking_number'
Cause: A hand-written __eq__ directly accessed other.tracking_number without checking that 'other' is actually a Shipment instance first — comparing to None or an unrelated type crashes instead of returning False.
Fix: Add an isinstance(other, Shipment) check at the top of __eq__, returning NotImplemented (not False directly) when it fails — this lets Python correctly fall back to the other object's own comparison logic if it has any.

🎯 Key Takeaways

  • Python has no enforced private keyword — a single underscore (_attr) is a pure convention; a double underscore (__attr) triggers name mangling for collision avoidance, not true privacy.
  • __str__ controls print()/f-string output for humans; __repr__ controls the debugging/REPL representation and is what a list of objects displays. Define __repr__ if you only pick one.
  • Defining __eq__ silently disables the default __hash__ — define __hash__ too, consistent with __eq__, or instances become unhashable or behave incorrectly in sets/dicts.
  • __lt__ (optionally combined with @functools.total_ordering) enables sorted() and comparison operators on custom objects.
  • Operator dunders like __add__ let custom objects use natural arithmetic syntax — genuinely common in real libraries (pandas, NumPy, datetime), not just an academic exercise.
  • __len__ and __getitem__ let a custom object support len() and indexing; __getitem__ alone is even enough to make an object iterable in a for loop.

What comes next

Module 23 covers @classmethod, @staticmethod, and @property — three decorators every real Python class eventually reaches for, and exactly when each one is the right tool.

Module 23 → Class Methods, Static Methods and Properties
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...