Python OOP Interview Questions: Concepts, Code & Gotchas
- 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.
Think of Object-Oriented Programming like building with LEGO. Each LEGO brick type is a 'class' — a blueprint that says what shape the brick is and what it can do. When you actually snap a brick into your model, that's an 'object' — a real thing built from the blueprint. Inheritance is like a special brick that's based on an existing one but has an extra knob. You don't redesign the whole brick; you just extend what's already there.
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: Defining the interface. # We don't care HOW a gateway works, only that it can authorize and capture. class PaymentGateway(ABC): @abstractmethod def authorize(self, amount: float) -> bool: pass @abstractmethod def capture(self, transaction_id: str) -> bool: pass # INHERITANCE: Specific implementation of the abstraction. class StripeGateway(PaymentGateway): def __init__(self, api_secret: str): # ENCAPSULATION: Internal credential hidden from the public API self.__api_secret = api_secret def authorize(self, amount: float) -> bool: print(f"[Stripe] Authorizing ${amount}...") return True def capture(self, transaction_id: str) -> bool: print(f"[Stripe] Capturing {transaction_id}") return True # POLYMORPHISM: The caller treats all gateways the same. def process_order(gateway: PaymentGateway, amount: float): if gateway.authorize(amount): gateway.capture("tx_123") gateway = StripeGateway(api_secret="sk_test_4eC39HqLyj") process_order(gateway, 99.99)
[Stripe] Capturing tx_123
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. 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.
import uuid # COMPOSITION: Behaviours as separate components class Logger: def log(self, message: str): print(f"[LOG] {message}") class Repository: def save(self, data: dict): print(f"[DB] Saving {data['id']}") # The Application Service HAS-A logger and HAS-A repository class UserService: def __init__(self, logger: Logger, repo: Repository): self.logger = logger self.repo = repo def create_user(self, username: str): user_id = str(uuid.uuid4()) self.logger.log(f"Creating user {username}") self.repo.save({"id": user_id, "username": username}) # Usage logger = Logger() repo = Repository() service = UserService(logger, repo) service.create_user("dev_forge")
[DB] Saving <uuid>
PDF inheriting from Printer just to get a format() method. That's composition's job — inject a Formatter object instead.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 DatabaseConnection: _instances = 0 def __init__(self, connection_string: str): self._connection_string = connection_string self.__class__._instances += 1 # Dunder for debugging def __repr__(self): return f"DatabaseConnection('{self._connection_string}')" # Property for controlled access @property def status(self) -> str: return "Connected" if self._connection_string else "Disconnected" # Class Method: Alternative Constructor @classmethod def from_env(cls): # In real world: fetch from os.getenv return cls("postgres://localhost:5432/forge") # Static Method: Pure utility @staticmethod def is_valid_uri(uri: str) -> bool: return uri.startswith("postgres://") conn = DatabaseConnection.from_env() print(f"Status: {conn.status}") print(f"Representation: {repr(conn)}") print(f"Is Valid? {DatabaseConnection.is_valid_uri('http://bad.uri')}")
Representation: DatabaseConnection('postgres://localhost:5432/forge')
Is Valid? False
Employee.from_full_name() as your example. Concrete examples win interviews.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.
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() if it wants all initialisers to run correctly.super().__init__()
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.
class Base: def __init__(self): print("Base init") class MixinA(Base): def __init__(self): print("MixinA init") super().__init__() class MixinB(Base): def __init__(self): print("MixinB init") super().__init__() class Diamond(MixinA, MixinB): def __init__(self): print("Diamond init") super().__init__() # Triggering the diamond d = Diamond() print(f"MRO: {[c.__name__ for c in Diamond.mro()]}")
MixinA init
MixinB init
Base init
MRO: ['Diamond', 'MixinA', 'MixinB', 'Base', 'object']
super().__init__(), the MRO chain breaks silently — classes further down the chain never run their initialisers. You'll get AttributeErrors that look completely unrelated to the missing super() call. Always call super().__init__(args, *kwargs) in every class that's meant to participate in multiple inheritance.| 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
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?
- QImplement a Singleton pattern in Python using a metaclass or the __new__ method. Explain the trade-offs.
- QHow do Abstract Base Classes (ABCs) differ from Interfaces in Java/C#, and how does Python's 'Duck Typing' interact with them?
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.
How do you implement a private attribute in Python?
Python uses name mangling for attributes with a double underscore (e.g., __secret). This transforms self.__secret into _ClassName__secret to prevent accidental access. However, it's not a strict access modifier. For standard encapsulation, use a single underscore _secret to signal internal use or the @property decorator to manage access logic.
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.