Encapsulation and Magic/Dunder Methods
Python's convention-based privacy, and the dunder methods that make your objects behave like built-in types.
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.
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."
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.
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_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.
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.
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>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__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.
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.
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 identicalclass 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__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.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 valueA 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.
__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.
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__ automaticallyfrom 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 twoclass 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
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)A Vanishing-Duplicates Bug at a Denver Logistics Company
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.
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.
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 nowThe 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.
Four Misconceptions About Encapsulation and Dunders
5 Interview Questions — With Complete Answers
Encapsulation & Dunder Mistakes Beginners Make Constantly
Errors You Will Hit With Encapsulation & Dunders — And Exactly Why
🎯 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 PropertiesDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.