Learnixo
Back to blog
AI Systemsintermediate

Magic Methods: __str__, __len__, __eq__

Master Python magic methods (dunder methods): __str__, __repr__, __len__, __eq__, __hash__, __contains__, __iter__, and how they make custom classes feel native.

Asma Hafeez KhanMay 16, 20266 min read
PythonMagic MethodsDunder Methods__str____eq____iter__OOP
Share:š•

What are Magic Methods?

Magic methods (also called dunder methods — "double underscore") let your custom classes hook into Python's built-in syntax and protocols. When you write print(obj), Python calls obj.__str__(). When you write len(obj), it calls obj.__len__().

They make custom classes feel like native Python types.


__str__ and __repr__

Python
class Drug:
    def __init__(self, name: str, category: str, dose_mg: float):
        self.name = name
        self.category = category
        self.dose_mg = dose_mg

    def __str__(self) -> str:
        """Human-readable: called by print() and str()."""
        return f"{self.name} ({self.category}, {self.dose_mg}mg)"

    def __repr__(self) -> str:
        """Developer-facing: called in REPL, debuggers, and repr()."""
        return f"Drug(name={self.name!r}, category={self.category!r}, dose_mg={self.dose_mg})"


warfarin = Drug("warfarin", "anticoagulant", 5.0)

print(warfarin)       # "warfarin (anticoagulant, 5.0mg)" — uses __str__
print(repr(warfarin)) # "Drug(name='warfarin', category='anticoagulant', dose_mg=5.0)" — uses __repr__
print([warfarin])     # [Drug(name='warfarin', ...)] — list repr uses __repr__ for elements

# !r in f-string: apply repr() to the value (adds quotes around strings)
name = "warfarin"
print(f"{name}")    # warfarin
print(f"{name!r}")  # 'warfarin'

Rule: __repr__ should be unambiguous and ideally valid Python to recreate the object. __str__ should be readable for end users.


__len__ and __bool__

Python
class DrugFormulary:
    def __init__(self):
        self._drugs: dict[str, Drug] = {}

    def add(self, drug: Drug) -> None:
        self._drugs[drug.name.lower()] = drug

    def __len__(self) -> int:
        """Called by len(formulary)."""
        return len(self._drugs)

    def __bool__(self) -> bool:
        """Called by if formulary: — True if non-empty."""
        return bool(self._drugs)   # Or: return len(self) > 0


formulary = DrugFormulary()
print(len(formulary))   # 0
if not formulary:
    print("Formulary is empty")

formulary.add(warfarin)
print(len(formulary))   # 1
if formulary:
    print("Formulary has drugs")

__eq__ and __hash__

Python
class DrugInteraction:
    def __init__(self, drug_a: str, drug_b: str, severity: str):
        # Store in canonical order so (warfarin, aspirin) == (aspirin, warfarin)
        self.pair = tuple(sorted([drug_a.lower(), drug_b.lower()]))
        self.severity = severity

    def __eq__(self, other) -> bool:
        """Called by ==."""
        if not isinstance(other, DrugInteraction):
            return NotImplemented   # Let Python try the other side
        return self.pair == other.pair

    def __hash__(self) -> int:
        """Required for use in sets or as dict keys — must be consistent with __eq__."""
        return hash(self.pair)   # Same pair → same hash

    def __repr__(self) -> str:
        return f"DrugInteraction({self.pair[0]!r}, {self.pair[1]!r}, {self.severity!r})"


# Now interactions can be compared and stored in sets
i1 = DrugInteraction("warfarin", "aspirin", "Major")
i2 = DrugInteraction("aspirin", "warfarin", "Major")   # Same pair, different order
i3 = DrugInteraction("warfarin", "metformin", "Minor")

print(i1 == i2)   # True — canonical order makes them equal
print(i1 == i3)   # False

interaction_set = {i1, i2, i3}
print(len(interaction_set))   # 2 — i1 and i2 are the same

Rule: If you define __eq__, you must define __hash__ if you want objects to be hashable (usable in sets or as dict keys). When __eq__ is defined without __hash__, Python sets __hash__ = None, making the class unhashable.


__contains__: The in Operator

Python
class DrugFormulary:
    def __init__(self):
        self._drugs: dict[str, Drug] = {}

    def add(self, drug: Drug) -> None:
        self._drugs[drug.name.lower()] = drug

    def __contains__(self, drug_name: str) -> bool:
        """Called by: drug_name in formulary."""
        return drug_name.lower() in self._drugs


formulary = DrugFormulary()
formulary.add(warfarin)

print("warfarin" in formulary)    # True
print("WARFARIN" in formulary)    # True — normalized
print("penicillin" in formulary)  # False

__iter__ and __next__: Making Objects Iterable

Python
class DrugBatch:
    """An iterable collection of drugs."""

    def __init__(self, drugs: list[Drug]):
        self._drugs = drugs
        self._index = 0

    def __iter__(self):
        """Called at the start of a for loop."""
        self._index = 0
        return self   # Return iterator (self in this case)

    def __next__(self) -> Drug:
        """Called on each iteration step."""
        if self._index >= len(self._drugs):
            raise StopIteration   # Signals end of iteration
        drug = self._drugs[self._index]
        self._index += 1
        return drug

    def __len__(self) -> int:
        return len(self._drugs)


batch = DrugBatch([warfarin, aspirin])

for drug in batch:
    print(drug)

# Can also convert to list
print(list(batch))   # [warfarin (anticoagulant, 5.0mg), ...]

# Simpler alternative: just implement __iter__ as a generator
class SimpleDrugBatch:
    def __init__(self, drugs: list[Drug]):
        self._drugs = drugs

    def __iter__(self):
        yield from self._drugs   # Generator handles __next__ automatically

__getitem__ and __setitem__: Index Access

Python
class DrugFormulary:
    def __init__(self):
        self._drugs: dict[str, Drug] = {}

    def add(self, drug: Drug) -> None:
        self._drugs[drug.name.lower()] = drug

    def __getitem__(self, name: str) -> Drug:
        """Called by formulary["warfarin"]."""
        key = name.lower()
        if key not in self._drugs:
            raise KeyError(f"Drug '{name}' not in formulary")
        return self._drugs[key]

    def __setitem__(self, name: str, drug: Drug) -> None:
        """Called by formulary["warfarin"] = drug."""
        self._drugs[name.lower()] = drug

    def __delitem__(self, name: str) -> None:
        """Called by del formulary["warfarin"]."""
        del self._drugs[name.lower()]


formulary = DrugFormulary()
formulary["warfarin"] = warfarin      # __setitem__
print(formulary["warfarin"])          # __getitem__
del formulary["warfarin"]             # __delitem__

Arithmetic Operators

Python
from dataclasses import dataclass

@dataclass
class Score:
    value: float

    def __add__(self, other: "Score") -> "Score":
        return Score(self.value + other.value)

    def __mul__(self, factor: float) -> "Score":
        return Score(self.value * factor)

    def __lt__(self, other: "Score") -> bool:
        return self.value < other.value

    def __repr__(self) -> str:
        return f"Score({self.value:.3f})"


s1 = Score(0.85)
s2 = Score(0.72)

print(s1 + s2)   # Score(1.570)
print(s1 * 1.1)  # Score(0.935)
print(s1 < s2)   # False
print(sorted([s2, s1]))  # [Score(0.720), Score(0.850)] — uses __lt__

Common Magic Methods Reference

| Method | Triggered by | Return type | |---|---|---| | __str__ | print(obj), str(obj) | str | | __repr__ | repr(obj), REPL display | str | | __len__ | len(obj) | int | | __bool__ | if obj:, bool(obj) | bool | | __eq__ | obj == other | bool | | __hash__ | hash(obj), obj in set | int | | __contains__ | item in obj | bool | | __iter__ | for x in obj: | iterator | | __next__ | next(obj) | item | | __getitem__ | obj[key] | item | | __setitem__ | obj[key] = value | None | | __delitem__ | del obj[key] | None | | __add__ | obj + other | new obj | | __lt__ | obj < other | bool | | __call__ | obj(args) | anything |


__call__: Making Objects Callable

Python
class PromptFormatter:
    def __init__(self, template: str):
        self.template = template

    def __call__(self, **kwargs) -> str:
        """Makes formatter callable: formatter(drug="warfarin")"""
        return self.template.format(**kwargs)


clinical_prompt = PromptFormatter(
    "You are a clinical pharmacist. Answer this question about {drug}: {question}"
)

# Use like a function
prompt = clinical_prompt(drug="warfarin", question="What are the main interactions?")
print(prompt)
# "You are a clinical pharmacist. Answer this question about warfarin: What are the main interactions?"

This is how LangChain's Runnable protocol works — .invoke() and | (pipe) are made possible by magic methods under the hood.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.