Inheritance in Python

In earlier lessons we built a Person class — name, age, a greet() method. Now we need a Student class. A student has a name and age (same as a person), can greet() (students also introduce themselves), but also has a school and grades, and greets in their own way — mentioning their school.

We could copy all the Person code into Student and tack on the new bits. But if we ever fix something in Person, the copy won't get the update. Duplicated code is a delayed bug.

Inheritance lets us say: "Student is a Person, plus a few extras." Don't copy — extend the existing class.

Creating a child class

To inherit from another class, you put the parent's name in parentheses after the child class name:

Python 3.13
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        return f"Hi, my name is {self.name}, I'm {self.age} years old."

class Student(Person):   # Student inherits from Person
    pass                  # nothing new yet

# Create a student
student = Student("Anna", 20)

# greet() and attributes come from Person
print(student.name)
Anna
print(student.greet())
Hi, my name is Anna, I'm 20 years old.

We haven't written a single line inside Student, yet it works — because it inherited __init__ and greet() from Person. That's inheritance at its simplest.

Illustration: Person class with attributes name, age and method greet, below it Student(Person) class with additional fields school, grades and method study, an arrow from Student to Person showing the inheritance link, and an annotation explaining that Student also receives everything from Person

Adding new attributes with super()

Now let's give the student a school and grades. We need to extend __init__: accept the old parameters (name, age) and the new ones (school, grades). To avoid duplicating the self.name/self.age setup, we'll call the parent's __init__ via super():

Python 3.13
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        return f"Hi, my name is {self.name}, I'm {self.age} years old."

class Student(Person):
    def __init__(self, name, age, school):
        super().__init__(name, age)   # let Person set name and age
        self.school = school
        self.grades = []

    def add_grade(self, grade):
        self.grades.append(grade)

student = Student("Anna", 20, "MIT")
student.add_grade(5)
student.add_grade(4)

print(student.greet())            # inherited method
Hi, my name is Anna, I'm 20 years old.
print(student.school, student.grades)
MIT [5, 4]

super() is a reference to "the parent of the current class". super().__init__(name, age) means "call Person's __init__ with name and age". We reuse the parent's logic instead of copying it.

Overriding methods

A child class can override a method from its parent — define its own behavior under the same name. If Student has its own greet(), Python calls that one, not the parent's:

Python 3.13
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        return f"Hi, my name is {self.name}."

class Student(Person):
    def __init__(self, name, age, school):
        super().__init__(name, age)
        self.school = school

    def greet(self):
        # Use the parent's greet() and add our own bit
        return f"{super().greet()} I study at {self.school}."

person = Person("Ivan", 30)
student = Student("Anna", 20, "MIT")

print(person.greet())
Hi, my name is Ivan.
print(student.greet())
Hi, my name is Anna. I study at MIT.

Inside an overridden method you can still call super().greet() to reuse the parent's logic rather than repeat it.

Multi-level hierarchies

Inheritance can chain: Student extends Person, and GraduateStudent extends Student. The descendant has access to everything above it in the chain:

Python 3.13
class Person:
    def __init__(self, name):
        self.name = name

    def greet(self):
        return f"Hi, I'm {self.name}."

class Student(Person):
    def __init__(self, name, school):
        super().__init__(name)
        self.school = school

class GraduateStudent(Student):
    def __init__(self, name, school, advisor):
        super().__init__(name, school)
        self.advisor = advisor

grad = GraduateStudent("Anna", "MIT", "Prof. Smith")

# Method from Person, attributes from every level
print(grad.greet())
Hi, I'm Anna.
print(grad.school, "/", grad.advisor)
MIT / Prof. Smith

When Python looks up a method or attribute, it walks the chain: current class first, then the parent, then the parent's parent — until it finds something.

Type checks: isinstance and issubclass

isinstance(obj, Class) checks whether an object is an instance of a class (or any of its subclasses). issubclass(A, B) checks whether class A descends from B:

Python 3.13
class Person:
    pass

class Student(Person):
    pass

student = Student()

# A Student is a kind of Person
print(isinstance(student, Student))
True
print(isinstance(student, Person))
True
# Class-to-class relationships
print(issubclass(Student, Person))
True
print(issubclass(Person, Student))
False

The key idea: a student is a person (isinstance(student, Person) is True), but a person is not necessarily a student. Inheritance defines an "is-a" relationship that only goes one way.

What about multiple inheritance?

Python lets a class have multiple parents: class Duck(Flying, Swimming):. It works, but the method lookup order in such hierarchies quickly becomes non-obvious (there's a separate algorithm called Method Resolution Order, MRO). In practice, single inheritance covers the vast majority of real code and is easier to read. Multiple inheritance is something to learn later, in an advanced course.

Understanding check

What happens when a method is called from a child class that doesn't override that method of the parent class?


In the next lesson we'll tackle the second principle of OOP — encapsulation: how to hide a class's internals behind an interface and why that makes code more resilient to change.