Basic Method Overriding in Python: Complete Object-Oriented Programming Inheritance Tutorial

Basic method overriding in python

Basic method overriding in python

Method overriding was the concept that made object-oriented programming actually click for me. Before I understood it properly, inheritance felt like a way to avoid retyping code. After I understood it, I realized it’s really about letting a subclass customize behavior while still being treated as an instance of its parent type everywhere that matters. This guide walks through everything I’ve learned about overriding methods in Python — from the basic syntax to the internal mechanics of how Python actually decides which method gets called.

What Method Overriding Actually Means

Method overriding happens when a subclass defines a method with the same name as one already defined in its parent class, replacing (or extending) that behavior for instances of the subclass. It’s different from method overloading (which many other languages support and Python doesn’t, at least not in the traditional sense) — overriding is about redefining existing behavior down the inheritance chain, not defining multiple versions of a method with different argument signatures.

class Animal:
    def speak(self):
        return "Some generic animal sound"

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

animals = [Animal(), Dog(), Cat()]
for animal in animals:
    print(animal.speak())

Output:

Some generic animal sound
Woof!
Meow!

Each subclass provides its own version of speak(), and calling .speak() on each object invokes the version appropriate to its actual class — this behavior, where the correct method is selected based on the object’s real type, is called polymorphism, and method overriding is the primary mechanism that makes it work.

The Method Resolution Order (MRO)

Understanding how Python decides which method to call is where this stops being magic. Every Python class has a Method Resolution Order — a defined sequence of classes Python searches, in order, when looking up an attribute or method. I can inspect it directly:

class Animal:
    def speak(self):
        return "Generic sound"

class Dog(Animal):
    def speak(self):
        return "Woof!"

print(Dog.__mro__)

Output:

(<class '__main__.Dog'>, <class '__main__.Animal'>, <class 'object'>)

When I call dog_instance.speak(), Python walks this tuple left to right, checking each class for a speak attribute, and uses the first one it finds. Since Dog defines its own speak(), that’s found first and Animal.speak() is never reached — this is the entire mechanism behind overriding. For simple single-inheritance hierarchies like this one, the MRO is just the chain of parent classes in order. For multiple inheritance, Python computes it using an algorithm called C3 linearization, which guarantees a consistent, predictable order even across complex class hierarchies.

Calling the Parent’s Version with super()

Overriding doesn’t have to mean completely replacing the parent’s behavior — often I want to extend it, running the parent’s version and then adding something extra. super() is how I reach back up the MRO to call the next class’s version of a method.

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def get_details(self):
        return f"Name: {self.name}, Salary: {self.salary}"

class Manager(Employee):
    def __init__(self, name, salary, team_size):
        super().__init__(name, salary)  # let the parent handle name/salary setup
        self.team_size = team_size

    def get_details(self):
        base_details = super().get_details()  # get the parent's formatted string
        return f"{base_details}, Team Size: {self.team_size}"

manager = Manager("Alice", 95000, 8)
print(manager.get_details())

Output:

Name: Alice, Salary: 95000, Team Size: 8

This pattern — call super(), then add subclass-specific behavior — is by far the most common way I actually override methods in real code. Completely replacing a parent method without calling super() at all is less common in practice, usually reserved for cases where the subclass’s behavior is genuinely unrelated to the parent’s implementation.

Overriding init Specifically

__init__ deserves special mention because it’s the method I override most often, and forgetting super().__init__() here is one of the most common bugs I’ve both made and seen others make.

class Vehicle:
    def __init__(self, make, model):
        self.make = make
        self.model = model
        self.is_running = False

    def start(self):
        self.is_running = True
        print(f"{self.make} {self.model} started")

class ElectricVehicle(Vehicle):
    def __init__(self, make, model, battery_capacity):
        super().__init__(make, model)  # without this, self.make/self.model never get set
        self.battery_capacity = battery_capacity

ev = ElectricVehicle("Tesla", "Model 3", 75)
ev.start()
print(f"Battery: {ev.battery_capacity} kWh")

If I forget super().__init__(make, model) here, self.make and self.model simply never get created on the instance, and calling ev.start() — which is inherited unchanged from Vehicle and references self.make — raises an AttributeError the moment it tries to use an attribute that was never set.

Overriding Built-in Dunder Methods

Some of the most useful overriding I do involves Python’s special “dunder” (double-underscore) methods, which control how built-in operations behave on custom objects.

class Money:
    def __init__(self, amount, currency='USD'):
        self.amount = amount
        self.currency = currency

    def __str__(self):
        return f"{self.amount:.2f} {self.currency}"

    def __repr__(self):
        return f"Money({self.amount}, '{self.currency}')"

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency

    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)

price1 = Money(19.99)
price2 = Money(5.01)

print(price1)                     # uses __str__
print(price1 + price2)            # uses __add__, then __str__ on the result
print(price1 == Money(19.99))     # uses __eq__

Output:

19.99 USD
25.00 USD
True

Overriding __eq__, __add__, __str__, and similar dunder methods is technically still method overriding — every class implicitly inherits from object, which already defines default (if unhelpful) versions of these methods, and I’m replacing that default behavior for my own class.

Extending Behavior Instead of Fully Replacing It

A pattern I use constantly: override a method, do some validation or logging, and then delegate to the parent’s original implementation for the actual work.

class Logger:
    def log(self, message):
        print(f"[LOG] {message}")

class TimestampedLogger(Logger):
    def log(self, message):
        import datetime
        timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        super().log(f"[{timestamp}] {message}")

logger = TimestampedLogger()
logger.log("Application started")

Output:

[LOG] [2026-07-30 10:15:32] Application started

Internal Working: How Attribute Lookup Actually Finds the Overridden Method

When I write instance.method_name(), Python’s attribute lookup mechanism first checks the instance’s own __dict__ for method_name (this is where instance attributes set via self.x = ... live, but generally not methods, unless explicitly assigned there). If not found there, it proceeds through the class’s __mro__ in order, checking each class’s own __dict__ for the attribute, and returns the first match — this is exactly the mechanism I demonstrated with Dog.__mro__ earlier.

Because this lookup happens at call time, not at class-definition time, the behavior is fully dynamic — if a subclass’s method is reassigned or monkey-patched after the class is defined, the new version is what gets found on the next call, since Python re-walks the MRO every single time the attribute is accessed rather than caching a fixed binding.

Overriding in Multiple Inheritance

Things get more interesting with multiple inheritance, where the C3 linearization algorithm determines a single, consistent MRO across multiple parent classes.

class Flyable:
    def move(self):
        return "Flying through the air"

class Swimmable:
    def move(self):
        return "Swimming through water"

class Duck(Flyable, Swimmable):
    pass

duck = Duck()
print(duck.move())
print(Duck.__mro__)

Output:

Flying through the air
(<class '__main__.Duck'>, <class '__main__.Flyable'>, <class '__main__.Swimmable'>, <class 'object'>)

Since Duck inherits from Flyable first, and neither Duck nor Flyable define anything unusual, Flyable.move() wins because it appears before Swimmable in the MRO. If I wanted Duck to genuinely combine both behaviors, I’d override move() in Duck itself and call both parent versions explicitly, since implicit MRO resolution only picks one.

class Duck(Flyable, Swimmable):
    def move(self):
        return f"{Flyable.move(self)}, then {Swimmable.move(self)}"

duck = Duck()
print(duck.move())

Common Mistakes I’ve Made

Real-World Use Cases

  1. Framework customization — overriding lifecycle methods in web frameworks (like Django’s save() on a model, or a custom clean() validation method).
  2. Custom exception classes overriding __init__ and __str__ to provide richer error messages.
  3. Plugin/strategy architectures where a base class defines an interface and subclasses override specific methods to provide different behaviors.
  4. Testing — overriding methods in mock/stub subclasses to isolate behavior during unit tests.

FAQs

What’s the difference between method overriding and method overloading? Overriding redefines an inherited method in a subclass. Overloading — having multiple methods with the same name but different parameters — isn’t natively supported in Python the way it is in Java or C++; Python instead uses default arguments, *args/**kwargs, or the functools.singledispatch decorator to achieve similar flexibility.

Do I always need to call super() when overriding? No — only when you want to reuse or extend the parent’s behavior. If the subclass’s implementation is meant to fully replace the parent’s, omitting super() is intentional and correct.

What happens if I override a method with a different number of parameters? Python allows it, since there’s no compile-time signature checking, but it can break code that calls the method expecting the parent’s original signature — this is a design smell to watch for, since it usually violates the expectation that a subclass can be used anywhere the parent is expected (the Liskov Substitution Principle).

How do I call a specific ancestor’s method, not just the immediate parent’s? You can call it explicitly by class name, e.g. Flyable.move(self), bypassing the standard MRO-based super() lookup, as shown in the multiple inheritance example above.

Why did overriding eq break my set/dict usage? Defining __eq__ without also defining __hash__ sets __hash__ to None automatically in Python 3, making instances unhashable. Define both together if you need the object to be usable in sets or as dict keys.

Summary

Method overriding is how Python achieves polymorphism — the same method call producing different, type-appropriate behavior depending on an object’s actual class. The mechanism behind it, the Method Resolution Order, is a well-defined, inspectable sequence that Python walks on every attribute lookup, which is why super() reliably reaches the “next” implementation in that chain rather than some fixed, hardcoded parent. Understanding the MRO — especially once multiple inheritance enters the picture — turned overriding from something I used by trial and error into something I can reason about precisely, every time.

References

Exit mobile version