Questions ranging from L3 (junior/mid) to L6 (staff engineer) level. Each answer includes the depth expected at a senior Python interview.
Expected answer:
Dunder methods (double-underscore methods like __len__, __add__) are Python's mechanism for letting user-defined classes integrate with built-in language constructs. They're called by the Python interpreter in response to operations like len(), +, or in.
Python uses this design (rather than requiring inheritance from a base class) to support duck typing: you don't need to declare "I am a Sequence" — you just implement __len__ and __getitem__ and Python treats you as one.
Expected answer:
__repr__: Developer-facing. Should be unambiguous, ideally reconstructable. Called byrepr(), the REPL, andf"{obj!r}". If__str__is absent,str()falls back to__repr__.__str__: User-facing. Should be readable and concise. Called byprint(),str(), andf"{obj}".
Rule of thumb: Always implement __repr__. Implement __str__ only when the user representation should differ from the debug representation.
Expected answer: Several reasons:
- Consistency:
len()works uniformly on strings, lists, dicts, custom objects. A.lengthattribute would be inconsistent (is it.length,.size,.count?). - C-level optimization: For built-in types,
len()directly accesses the C-levelob_sizefield ortp_as_sequence->sq_length. This is ~50ns — faster than any Python attribute lookup. - Protocol enforcement:
len()validates the return value (must be non-negative int). A raw attribute has no such guarantee. - Pythonic design: "There should be one obvious way to do it."
Expected answer: Python's binary operator dispatch:
- Call
type(a).__add__(a, b) - If that returns
NotImplemented, calltype(b).__radd__(b, a) - If that also returns
NotImplemented, raiseTypeError
Special case: if type(b) is a subclass of type(a), Python tries type(b).__radd__ first. This lets subclasses override behavior even when they appear on the right side.
Expected answer:
Sets and dict keys require objects to be hashable — meaning they must implement __hash__. Lists are mutable: you can append, remove, or reorder elements. If a list's hash changed while it was stored in a set, the set would look in the wrong hash bucket and never find it again.
Python's contract: if two objects compare equal (==), they must have the same hash. This invariant breaks for mutable objects — so lists, dicts, and sets are intentionally unhashable.
Tuples are hashable (if their elements are hashable) because they're immutable.
Expected answer: Python checks a fallback chain:
__bool__defined? → call it, return the result__len__defined? → call it;Trueiflen(obj) > 0, elseFalse- Neither defined? →
True(all objects are truthy by default)
This has a subtle implication: if your class implements __len__ but not __bool__, it inherits truthiness from its size. An empty custom collection becomes falsy automatically — which is often the right behavior (mirrors list, dict, etc.).
Performance note: The __bool__ path is ~20–30% faster than the __len__ fallback because Python doesn't need to call len() and compare against zero.
Expected answer:
NotImplementedis a singleton sentinel value (likeNone). It's returned from binary operator methods to signal "I don't know how to handle this type — let Python try the reflected operator."NotImplementedErroris an exception, raised from abstract methods to signal "this method must be implemented by a subclass."
Returning NotImplementedError from __add__ is a bug — it stops Python from trying the reflected operator and immediately propagates the error up.
def __add__(self, other):
if not isinstance(other, MyType):
return NotImplemented # ✅ Let Python try other.__radd__
# NOT: raise NotImplementedError() ❌Expected answer:
In CPython, every type has a PyTypeObject struct that contains pointers to C functions implementing various operations. For sequences, tp_as_sequence->sq_length is this pointer.
For built-in list: len([1,2,3]) → PyObject_Size() → Py_TYPE(o)->tp_as_sequence->sq_length(o) → returns ob_size directly. This is one C function call — no Python dispatch, no attribute lookup.
For user-defined classes: len(obj) → PyObject_Size() → no sq_length slot → falls through to Python-level __len__ dispatch via _PyObject_LookupSpecial. This involves: type lookup, attribute access, and a Python function call — roughly 3–5x slower than the built-in path.
This explains why len(list) is ~50ns while len(UserClass) is ~90ns.
Q9: Python makes mutable objects unhashable by default when __eq__ is defined. Why is this a conservative but correct choice?
Expected answer:
When you define __eq__, Python sets __hash__ = None. The rationale: Python cannot know whether your object is safe to hash after you've defined custom equality. If your __eq__ uses mutable fields and you allow hashing, objects could end up in the wrong bucket after mutation — causing silent data corruption in sets and dicts.
The conservative default forces you to explicitly opt into hashability by implementing __hash__, which forces you to think about: "which fields should determine the hash? Are those fields immutable?"
This design follows the principle: it's better to fail loudly (TypeError: unhashable) than silently corrupt data.
These questions test not just Python knowledge but systems thinking and understanding of WHY Python is designed the way it is.