Python OOP Interview Questions: Concepts, Code & Gotchas
Python interviews at companies like Google, Stripe, and mid-sized startups almost always include OOP questions — not because the interviewers want you to recite definitions, but because how you talk about OOP reveals whether you actually design software or just write scripts. A candidate who can explain why encapsulation exists (not just what it is) stands out immediately from the crowd who memorised a textbook definition the night before.
OOP solves the problem of code that grows into an unmanageable tangle. Without it, adding a new feature means hunting through hundreds of lines of procedural code, hoping you don't break something else. With OOP, you model your problem as a collection of objects that each own their own data and behaviour — so changes stay contained, reuse becomes natural, and testing gets easier.
By the end of this article you'll be able to explain the four pillars of OOP with real examples, write and debug class hierarchies in Python, spot the classic mistakes candidates make under pressure, and answer the follow-up questions that actually trip people up in interviews.
The Four Pillars — What They Are and Why They Exist
Every OOP interview starts here. Interviewers don't just want the names — they want to see you connect each pillar to a real problem it solves.
Encapsulation bundles data and the methods that act on it into one unit, and hides the messy internals. Think of a TV remote: you press a button, the TV changes channel. You don't need to know about the infrared signal. The remote encapsulates that complexity.
Inheritance lets a new class reuse behaviour from an existing one. Instead of copy-pasting a send_email method into five classes, you put it in a base Notification class and let the others inherit it. Changes in one place propagate everywhere.
Polymorphism means different objects can respond to the same method call in their own way. You call .render() on a Button and on a Chart — Python figures out which version to run. Your calling code doesn't need an if-else chain.
Abstraction hides what the implementation does and exposes only what it accomplishes. An abstract PaymentProcessor class forces every subclass (Stripe, PayPal) to implement .charge(), but callers never care which one they're using.
from abc import ABC, abstractmethod # ABSTRACTION: Define a contract that all payment processors must follow. # Callers only need to know about .charge() — not HOW it works. class PaymentProcessor(ABC): @abstractmethod def charge(self, amount: float, currency: str) -> str: """Every subclass MUST implement this method.""" pass # INHERITANCE: StripeProcessor reuses the interface from PaymentProcessor. # We only write what's different — the Stripe-specific logic. class StripeProcessor(PaymentProcessor): def __init__(self, api_key: str): # ENCAPSULATION: _api_key is 'private by convention' (single underscore). # External code SHOULD NOT read or write this directly. self._api_key = api_key def charge(self, amount: float, currency: str) -> str: # In real life this would call the Stripe SDK. return f"[Stripe] Charged {amount} {currency} using key ending ...{self._api_key[-4:]}" class PayPalProcessor(PaymentProcessor): def __init__(self, client_id: str): self._client_id = client_id def charge(self, amount: float, currency: str) -> str: return f"[PayPal] Charged {amount} {currency} via client {self._client_id}" # POLYMORPHISM: process_payment doesn't know or care which processor it gets. # It calls .charge() and Python dispatches to the right class automatically. def process_payment(processor: PaymentProcessor, amount: float, currency: str): result = processor.charge(amount, currency) print(result) # Create objects (instances) from our blueprints (classes) stripe = StripeProcessor(api_key="sk_live_ABCDEF123456") paypal = PayPalProcessor(client_id="CLIENT_XYZ") process_payment(stripe, 49.99, "USD") process_payment(paypal, 49.99, "USD")
[PayPal] Charged 49.99 USD via client CLIENT_XYZ
Inheritance vs Composition — The Question That Trips Everyone Up
Interviewers love asking 'when would you use inheritance over composition?' because most candidates either go blank or recite 'favour composition over inheritance' without knowing why.
Inheritance models an is-a relationship. A Dog is an Animal. That relationship is rigid — once you inherit from a class, you're locked into its interface and any side-effects of its implementation changes.
Composition models a has-a relationship. A Car has an Engine. You can swap the engine (electric vs petrol) without rewriting the car. This is why composition is usually more flexible.
The practical rule: use inheritance when the child genuinely is a specialised version of the parent and you want to enforce a shared contract (like our PaymentProcessor above). Use composition when you want to combine behaviours, because it keeps each piece small, testable, and swappable.
The classic trap is building deep inheritance chains (six levels deep) only to find that a change in the grandparent breaks three grandchildren in unpredictable ways. Composition avoids that cascade entirely.
# ── INHERITANCE APPROACH ────────────────────────────────────────────────── # Use this when the child IS a specialised version of the parent. class Animal: def __init__(self, name: str): self.name = name def breathe(self): # All animals breathe — this belongs in the base class. return f"{self.name} is breathing" class Dog(Animal): def speak(self): # Dogs speak differently from Cats — override only what changes. return f"{self.name} says: Woof!" class Cat(Animal): def speak(self): return f"{self.name} says: Meow!" # ── COMPOSITION APPROACH ───────────────────────────────────────────────── # Use this when the object HAS a behaviour, not when it IS a type of thing. class GPSModule: """A self-contained unit of GPS behaviour. Fully testable on its own.""" def get_location(self) -> str: return "lat=51.5074, lon=-0.1278" # London, hardcoded for demo class MusicPlayer: """A self-contained unit of audio behaviour.""" def play(self, track: str) -> str: return f"Now playing: {track}" class SmartCar: """ SmartCar HAS a GPS and HAS a MusicPlayer. It does NOT inherit from either — that would be bizarre. Swapping to a SatelliteGPS later? Just change the injected object. """ def __init__(self, gps: GPSModule, player: MusicPlayer): self._gps = gps # Composed in — not inherited self._player = player # Composed in — not inherited def navigate(self) -> str: return f"Navigating to {self._gps.get_location()}" def entertain(self, track: str) -> str: return self._player.play(track) # ── DEMO ───────────────────────────────────────────────────────────────── dog = Dog("Rex") cat = Cat("Whiskers") print(dog.breathe()) # Inherited from Animal print(dog.speak()) # Dog-specific print(cat.speak()) # Cat-specific — polymorphism at work my_car = SmartCar(gps=GPSModule(), player=MusicPlayer()) print(my_car.navigate()) print(my_car.entertain("Bohemian Rhapsody"))
Rex says: Woof!
Whiskers says: Meow!
Navigating to lat=51.5074, lon=-0.1278
Now playing: Bohemian Rhapsody
Dunder Methods, Properties, and Class vs Static Methods
These three topics show up constantly in Python OOP interviews because they reveal whether you understand Python's OOP model specifically — not just OOP in general.
Dunder (magic) methods let your objects integrate with Python's built-in syntax. Define __str__ so print(my_object) shows something meaningful. Define __eq__ so order_a == order_b compares the right fields. Define __len__ so len(my_cart) works naturally. They're called 'dunder' because they have Double UNDERscores on each side.
Properties (via @property) let you expose a clean attribute interface while hiding validation logic behind it. You read employee.salary like an attribute, but under the hood Python calls a getter method. The caller never sees the implementation change if you add validation later.
Class methods vs static methods trip up almost everyone. A @classmethod receives the class itself as its first argument (cls) — it can create instances, so it's perfect for alternative constructors. A @staticmethod receives nothing special — it's just a utility function that logically belongs inside the class namespace but doesn't need access to the class or instance.
class Employee: # Class variable: shared across ALL instances, not per-employee company_name = "TheCodeForge" _headcount = 0 # tracks how many employees exist def __init__(self, first_name: str, last_name: str, annual_salary: float): self.first_name = first_name self.last_name = last_name self._annual_salary = annual_salary # 'private' — use the property below Employee._headcount += 1 # ── DUNDER METHODS ─────────────────────────────────────────────────── def __str__(self) -> str: """Called by print() and str(). Should be human-readable.""" return f"{self.first_name} {self.last_name} @ {self.company_name}" def __repr__(self) -> str: """Called in the REPL and for debugging. Should be unambiguous.""" return f"Employee('{self.first_name}', '{self.last_name}', {self._annual_salary})" def __eq__(self, other: object) -> bool: """Two employees are equal if their full names match — define YOUR rule.""" if not isinstance(other, Employee): return NotImplemented # Let Python handle incompatible types gracefully return self.first_name == other.first_name and self.last_name == other.last_name # ── PROPERTY ───────────────────────────────────────────────────────── @property def annual_salary(self) -> float: """Getter: caller reads employee.annual_salary like a plain attribute.""" return self._annual_salary @annual_salary.setter def annual_salary(self, value: float): """Setter: enforces business rules — salary can't go negative.""" if value < 0: raise ValueError(f"Salary cannot be negative. Got: {value}") self._annual_salary = value # ── CLASS METHOD ───────────────────────────────────────────────────── @classmethod def from_full_name(cls, full_name: str, annual_salary: float) -> "Employee": """ Alternative constructor — receives `cls` so it can build an instance. Caller writes: emp = Employee.from_full_name('Ada Lovelace', 95000) """ first, last = full_name.split(" ", 1) return cls(first, last, annual_salary) # uses cls, not Employee, for subclass safety @classmethod def headcount(cls) -> int: """Reads class-level state — needs cls, so it's a classmethod.""" return cls._headcount # ── STATIC METHOD ──────────────────────────────────────────────────── @staticmethod def is_valid_salary(value: float) -> bool: """ Pure utility: no access to instance (self) or class (cls) needed. It just belongs here conceptually because it's about Employee salaries. """ return isinstance(value, (int, float)) and 0 <= value <= 10_000_000 # ── DEMO ───────────────────────────────────────────────────────────────── emp1 = Employee("Alice", "Smith", 80000) emp2 = Employee.from_full_name("Bob Johnson", 95000) # classmethod constructor print(emp1) # __str__ print(repr(emp2)) # __repr__ print(emp1 == Employee("Alice", "Smith", 999)) # __eq__ — salary ignored by our rule emp1.annual_salary = 85000 # calls the setter with validation print(f"New salary: {emp1.annual_salary}") print(f"Total employees: {Employee.headcount()}") print(f"Is 50000 valid? {Employee.is_valid_salary(50000)}") print(f"Is -100 valid? {Employee.is_valid_salary(-100)}")
Employee('Bob', 'Johnson', 95000)
True
New salary: 85000
Total employees: 3
Is 50000 valid? True
Is -100 valid? False
MRO, super(), and Multiple Inheritance — Python's Hidden Complexity
Multiple inheritance is rare in practice but almost always shows up in Python OOP interviews because it exposes whether you understand Python's Method Resolution Order (MRO).
When a class inherits from two parents that both define the same method, Python needs a rule for which one wins. That rule is the C3 linearisation algorithm, and the result is visible via ClassName.__mro__. Python reads it left to right — the first class in the MRO that defines the method wins.
super() doesn't just mean 'call the parent class'. In a multiple-inheritance chain, super() calls the next class in the MRO — which might not be the direct parent. This is the cooperative inheritance pattern, and it's why every class in a diamond hierarchy should call super().__init__() if it wants all initialisers to run correctly.
In practice, you'll see multiple inheritance most often with mixins — small classes that add a single, specific behaviour (like LoggingMixin or SerializableMixin) without being a full standalone class. It's composition-flavoured inheritance, and it's elegant when kept small.
# ── MIXIN PATTERN ───────────────────────────────────────────────────────── # Mixins are NOT meant to be instantiated alone. # They 'mix in' a single, focused behaviour to whatever class uses them. class TimestampMixin: """Adds created_at tracking to any class that includes it.""" def __init__(self, *args, **kwargs): import datetime # IMPORTANT: always pass *args/**kwargs up the chain so other # __init__ methods in the MRO also receive their arguments. super().__init__(*args, **kwargs) self.created_at = datetime.datetime.utcnow().strftime("%Y-%m-%d") class SerializableMixin: """Adds a to_dict() method to any class that includes it.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # cooperative — passes args along def to_dict(self) -> dict: # __dict__ holds all instance attributes as a plain dictionary return self.__dict__ class BaseModel: """Minimal base — every model has an id.""" def __init__(self, model_id: int): self.model_id = model_id # Order of inheritance matters! Python builds the MRO left to right. # MRO here: BlogPost -> TimestampMixin -> SerializableMixin -> BaseModel -> object class BlogPost(TimestampMixin, SerializableMixin, BaseModel): def __init__(self, model_id: int, title: str, author: str): # super().__init__() kicks off the MRO chain. # TimestampMixin runs first, then SerializableMixin, then BaseModel. super().__init__(model_id=model_id) self.title = title self.author = author # ── DEMO ────────────────────────────────────────────────────────────────── post = BlogPost(model_id=42, title="Python OOP Deep Dive", author="Ada") print("Title:", post.title) print("Created at:", post.created_at) # from TimestampMixin print("Serialized:", post.to_dict()) # from SerializableMixin # See exactly how Python resolves method calls for this class print("\nMRO:") for klass in BlogPost.__mro__: print(" ->", klass.__name__)
Created at: 2025-01-15
Serialized: {'model_id': 42, 'created_at': '2025-01-15', 'title': 'Python OOP Deep Dive', 'author': 'Ada'}
MRO:
-> BlogPost
-> TimestampMixin
-> SerializableMixin
-> BaseModel
-> object
| Feature | @classmethod | @staticmethod | Instance Method |
|---|---|---|---|
| First argument | cls (the class) | None (no implicit arg) | self (the instance) |
| Can access instance state | No | No | Yes |
| Can access class state | Yes | No | Yes (via self.__class__) |
| Can create instances | Yes — perfect for this | Possible but awkward | Yes |
| Callable on class directly | Yes | Yes | Technically, but unusual |
| Primary use case | Alternative constructors | Utility / helper functions | Core object behaviour |
| Real-world example | Employee.from_csv(row) | Employee.is_valid_salary(n) | employee.get_pay_slip() |
🎯 Key Takeaways
- The four pillars only matter in interviews when you tie each one to a concrete problem it solves — 'encapsulation hides the Stripe API key from the calling code' beats 'encapsulation bundles data and methods' every time.
- Prefer composition over inheritance when you want to combine behaviours — inject a GPSModule into a SmartCar rather than making SmartCar inherit from GPSModule. Inheritance is for genuine 'is-a' relationships only.
- @classmethod is Python's pattern for alternative constructors because it receives cls and can therefore build and return an instance — that's the only reason it exists over @staticmethod.
- In any multiple-inheritance chain, always call super().__init__(args, *kwargs) — forgetting it silently breaks the MRO chain and causes AttributeErrors that look unrelated to the real problem.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Mutable default argument in __init__ — writing
def __init__(self, items=[])causes ALL instances to share the same list object. Add one item to instance A and it magically appears in instance B. Fix: usedef __init__(self, items=None)and setself.items = items if items is not None else []inside the body. - ✕Mistake 2: Forgetting to call super().__init__() in a subclass — when your subclass overrides __init__ without calling super(), the parent's initialisation code never runs. You'll get AttributeErrors on attributes that the parent was supposed to set. Fix: always add
super().__init__(args, *kwargs)as the first line of your subclass __init__ unless you have a deliberate reason not to. - ✕Mistake 3: Confusing __str__ and __repr__ — candidates define __str__ for debugging output, which means
repr(obj)in a REPL or a log file shows something useless like<__main__.Order object at 0x7f3a...>. The rule: __repr__ must be unambiguous and ideally show enough info to recreate the object; __str__ is for end-user display. Always define both.
Interview Questions on This Topic
- QWhat is the difference between a classmethod and a staticmethod in Python, and can you give a real-world use case where you'd choose each one?
- QExplain Python's Method Resolution Order. If class C inherits from both A and B, and both A and B define a method called process(), which one does Python call and why?
- QHow does Python enforce encapsulation? What's the difference between a single-underscore prefix, a double-underscore prefix, and using @property — and when would you reach for each?
Frequently Asked Questions
What Python OOP concepts are most commonly tested in interviews?
The four pillars (encapsulation, inheritance, polymorphism, abstraction) are always tested, but interviewers at technical companies dig deeper into Python-specific features: dunder methods, @property, @classmethod vs @staticmethod, MRO, and the difference between abstract base classes and duck typing. Knowing the pillar names isn't enough — you need a code example for each.
What is the difference between __str__ and __repr__ in Python?
__repr__ is for developers — it should return an unambiguous string that ideally lets you recreate the object (e.g. Employee('Alice', 'Smith', 80000)). __str__ is for end users — it can be friendlier and shorter (e.g. Alice Smith @ TheCodeForge). When only one is defined, Python falls back to __repr__, so always define __repr__ first.
Is Python truly object-oriented if it doesn't have true private attributes?
Python is object-oriented but takes a pragmatic approach: 'we're all adults here.' A single underscore (_name) is a strong convention meaning 'internal use only — don't touch this from outside.' A double underscore (__name) triggers name-mangling (it becomes _ClassName__name) which makes accidental external access harder but not impossible. Python relies on developer discipline rather than compiler enforcement — and in practice, this works well for the vast majority of codebases.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.