By the end of this chapter, you should understand:
- What inheritance means in Python.
- What parent classes and child classes are.
- How inherited attributes and methods are found.
- Why inheritance models is-a relationships.
- How inheritance differs from composition.
- What method overriding means.
- How child classes specialize parent behavior.
- How subclasses can reuse inherited behavior.
- Why
super()is used when extending parent behavior. - What inherited initialization means.
- How to avoid common inheritance mistakes.
- Why inheritance should be used carefully.
- How this chapter prepares us for MRO and
super()in depth.
Chapter 46 taught composition.
Composition says:
one object has or uses another object
Inheritance says:
one class is a specialized kind of another class
That distinction matters.
Inheritance is powerful.
It lets classes reuse and specialize behavior.
But it also creates tight relationships between classes.
Used well, inheritance makes code expressive.
Used poorly, it creates brittle hierarchies that are hard to change.
This chapter teaches inheritance carefully, with composition still fresh in mind.
Inheritance lets one class receive behavior from another class.
Example:
class Animal:
def speak(self):
return "some sound"
class Dog(Animal):
passUse:
dog = Dog()
print(dog.speak())Output:
some sound
Dog does not define speak.
Python finds speak on Animal.
Relationship:
Dog inherits from Animal
Dog is a child class
Animal is a parent class
Another set of terms:
Dog is a subclass of Animal
Animal is a superclass of Dog
These terms mean the same broad relationship.
Parent class:
class Animal:
def eat(self):
return "eating"Child class:
class Dog(Animal):
passThe parentheses:
class Dog(Animal):mean:
Dog inherits from Animal
Create:
dog = Dog()Call inherited method:
print(dog.eat())Output:
eating
Python's attribute lookup includes parent classes.
Simplified lookup:
instance
child class
parent class
parent's parent classes
This is how inherited methods are found.
Inheritance works through attribute lookup.
Example:
class Animal:
def speak(self):
return "sound"
class Dog(Animal):
pass
dog = Dog()When you call:
dog.speak()Python searches:
dog instance namespace
Dog class namespace
Animal class namespace
It finds speak in Animal.
Then it binds the method to dog.
This is not a separate magic system.
It extends the attribute lookup model from Chapter 44.
Inheritance is mostly about where Python searches when looking for attributes.
That idea will become essential in Chapter 48, where we study MRO.
Inheritance should usually model an is-a relationship.
Examples:
Dog is an Animal.
Car is a Vehicle.
SavingsAccount is an Account.
CsvReport is a Report.
AdminUser is a User.
If you can naturally say:
child is a kind of parent
inheritance may fit.
If the relationship is:
object has another object
object uses another object
object needs another object
composition may fit better.
Bad inheritance:
Car is an Engine.
UserService is a Database.
Report is a Formatter.
These are probably has-a or uses-a relationships.
Use composition for those.
Inheritance should express meaning, not merely code reuse.
Parent:
class Vehicle:
def __init__(self, name):
self.name = name
def describe(self):
return f"Vehicle: {self.name}"Child:
class Car(Vehicle):
passUse:
car = Car("Sedan")
print(car.describe())Output:
Vehicle: Sedan
Car inherits:
__init__describe
from Vehicle.
Because Car does not define its own __init__, Python uses the inherited one.
Because Car does not define its own describe, Python uses the inherited one.
This is basic inheritance:
child receives behavior from parent
Method overriding means a child class defines a method with the same name as a parent method.
Example:
class Animal:
def speak(self):
return "some sound"
class Dog(Animal):
def speak(self):
return "woof"Use:
animal = Animal()
dog = Dog()
print(animal.speak())
print(dog.speak())Output:
some sound
woof
Dog.speak overrides Animal.speak.
Lookup for dog.speak:
dog instance -> no speak
Dog class -> speak found
Animal class -> not needed
The child method is found first.
That is overriding.
Overriding lets a subclass specialize parent behavior.
Example:
class Report:
def render(self):
return "generic report"
class HtmlReport(Report):
def render(self):
return "<p>html report</p>"
class TextReport(Report):
def render(self):
return "text report"Use:
reports = [Report(), HtmlReport(), TextReport()]
for report in reports:
print(report.render())Output:
generic report
<p>html report</p>
text report
Each class has a render method.
The same call:
report.render()can produce different behavior depending on the object's class.
This is a foundation for polymorphism.
We will study that more deeply in Chapter 49.
If a child class does not define __init__, it inherits the parent's __init__.
Example:
class User:
def __init__(self, name):
self.name = name
class AdminUser(User):
passUse:
admin = AdminUser("Ada")
print(admin.name)Output:
Ada
AdminUser inherited User.__init__.
Lookup for __init__ during construction finds it on User.
This can be useful when the child class needs the same initialization as the parent.
But if the child needs extra state, it may define its own __init__.
Then we need to decide whether and how to call the parent initializer.
That is where super() enters.
A child class can define its own __init__.
Example:
class User:
def __init__(self, name):
self.name = name
class AdminUser(User):
def __init__(self, name, permissions):
self.name = name
self.permissions = permissionsUse:
admin = AdminUser("Ada", ["manage_users"])This works.
But it duplicates:
self.name = namefrom User.__init__.
Duplication can be small at first.
Later, if User.__init__ changes, AdminUser.__init__ may become inconsistent.
Better:
class AdminUser(User):
def __init__(self, name, permissions):
super().__init__(name)
self.permissions = permissionsThis calls the parent initializer.
Then the child adds its own state.
super() gives access to the next class in the method resolution order.
For simple single inheritance, beginners can read:
super().__init__(name)as:
call the parent class's __init__
Example:
class User:
def __init__(self, name):
self.name = name
class AdminUser(User):
def __init__(self, name, permissions):
super().__init__(name)
self.permissions = permissionsThis avoids duplicating parent initialization.
But super() is deeper than "call parent."
In multiple inheritance, it follows MRO.
Chapter 48 will study super() properly.
For now, the practical model is enough:
use super() when extending inherited behavior
Sometimes a child wants to add behavior before or after parent behavior.
Example:
class Report:
def render(self):
return "body"
class TitledReport(Report):
def render(self):
body = super().render()
return f"Title\n{body}"Use:
report = TitledReport()
print(report.render())Output:
Title
body
The child did not replace parent behavior entirely.
It extended it.
Pattern:
def method(self):
result = super().method()
return modified_resultThis is common.
It keeps parent behavior reusable while allowing child specialization.
Overriding can replace behavior completely.
Example:
class Animal:
def speak(self):
return "sound"
class Dog(Animal):
def speak(self):
return "woof"Dog.speak replaces Animal.speak.
Overriding can also extend behavior.
Example:
class Logger:
def log(self, message):
print(message)
class TimestampLogger(Logger):
def log(self, message):
super().log(f"[time] {message}")TimestampLogger.log uses parent behavior but changes the message.
Ask:
Do I want to replace the parent behavior?
Or build on it?
If building on it, use super() rather than copying the parent method's code.
Class attributes are inherited too.
Example:
class User:
role = "user"
class AdminUser(User):
passUse:
print(AdminUser.role)
print(AdminUser().role)Output:
user
user
Python finds role on the parent class.
A child can override class attributes:
class AdminUser(User):
role = "admin"Now:
print(AdminUser.role)
print(User.role)Output:
admin
user
The child class has its own class attribute named role.
It shadows the parent class attribute.
Instance attributes are usually created by methods.
Example:
class User:
def __init__(self, name):
self.name = nameChild:
class AdminUser(User):
passUse:
admin = AdminUser("Ada")The instance attribute name is not inherited as a stored value.
It is created when inherited User.__init__ runs on the AdminUser instance.
This distinction matters.
Class methods and attributes are inherited through lookup.
Instance attributes are per-object state created at runtime.
Better wording:
AdminUser inherits the method that creates name.
Each AdminUser instance gets its own name when initialized.
Not:
AdminUser inherits the name value.
isinstance() understands inheritance.
Example:
class Animal:
pass
class Dog(Animal):
pass
dog = Dog()Check:
print(isinstance(dog, Dog))
print(isinstance(dog, Animal))Output:
True
True
The dog is an instance of Dog.
It is also considered an instance of Animal.
Why?
Because Dog is a subclass of Animal.
This is one reason inheritance should model is-a relationships.
If:
isinstance(obj, Parent)is true, it should make conceptual sense.
issubclass() checks class relationships.
Example:
class Animal:
pass
class Dog(Animal):
passCheck:
print(issubclass(Dog, Animal))
print(issubclass(Animal, Dog))Output:
True
False
Dog is a subclass of Animal.
Animal is not a subclass of Dog.
Also:
print(issubclass(Dog, Dog))Output:
True
A class is considered a subclass of itself for this check.
Use issubclass() when you are reasoning about class relationships.
Use isinstance() when you are reasoning about objects.
In Python 3, all classes inherit from object eventually.
These are equivalent:
class User:
passand:
class User(object):
passYou usually write the shorter form:
class User:
passCheck:
print(issubclass(User, object))Output:
True
object provides basic behavior common to Python objects.
This includes methods like:
__str__
__repr__
__eq__
__getattribute__
We will study many dunder methods later.
For now, remember:
every normal class participates in a larger inheritance chain ending at object
Inheritance is not only about your own parent classes.
You can override behavior inherited from object.
Example:
class User:
def __init__(self, name):
self.name = name
def __str__(self):
return self.nameUse:
user = User("Ada")
print(str(user))
print(user)Output:
Ada
Ada
User overrides object.__str__.
This lets your object participate in Python's string conversion protocol.
Dunder methods are a major part of Python's data model.
We will study them in Part II.
This example shows that overriding can customize built-in behavior too.
Parent:
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def annual_pay(self):
return self.salaryChild:
class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary)
self.bonus = bonus
def annual_pay(self):
return self.salary + self.bonusUse:
employee = Employee("Ada", 100_000)
manager = Manager("Grace", 120_000, 20_000)
print(employee.annual_pay())
print(manager.annual_pay())Output:
100000
140000
This is a reasonable inheritance example if:
Manager is a kind of Employee
and manager-specific behavior specializes employee behavior.
Parent:
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area")Children:
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius * self.radiusUse:
shapes = [Rectangle(10, 5), Circle(3)]
for shape in shapes:
print(shape.area())Each shape has an area.
Each computes it differently.
This is a classic inheritance example.
Later, we will see how abstract base classes can express this more formally.
Sometimes a parent class defines a method that subclasses are expected to override.
Example:
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area")This means:
Shape defines the expected interface
but does not provide implementation
If a subclass forgets:
class Triangle(Shape):
passthen:
Triangle().area()raises:
NotImplementedError
This is a runtime signal.
It is not the same as a formal abstract method.
ABCs will give us stronger tools later.
For now, NotImplementedError is a simple way to say:
subclass responsibility
Polymorphism means different objects can be used through the same interface.
Example:
class Dog:
def speak(self):
return "woof"
class Cat:
def speak(self):
return "meow"No inheritance needed.
But inheritance can also support polymorphism:
class Animal:
def speak(self):
raise NotImplementedError
class Dog(Animal):
def speak(self):
return "woof"
class Cat(Animal):
def speak(self):
return "meow"Use:
animals = [Dog(), Cat()]
for animal in animals:
print(animal.speak())Same call.
Different behavior.
We will study polymorphism and duck typing in Chapter 49.
Inheritance and composition can work together.
Example:
class Formatter:
def format(self, data):
raise NotImplementedError
class TextFormatter(Formatter):
def format(self, data):
return str(data)
class Report:
def __init__(self, formatter):
self._formatter = formatter
def render(self, data):
return self._formatter.format(data)Here:
TextFormatter is a Formatter
Report has a Formatter
The formatter hierarchy uses inheritance.
The report uses composition.
Good design often combines both:
- Inheritance for families of related types.
- Composition for assembling objects.
The goal is not to avoid inheritance forever.
The goal is to use it where it expresses the relationship clearly.
Inheritance chains can become deep.
Example:
BaseReport
ExportableReport
FormattedReport
HtmlReport
InteractiveHtmlReport
Deep chains can be hard to understand.
Questions become difficult:
Where is this method defined?
Which class overrides it?
What state does each level require?
Which __init__ methods must run?
Inheritance depth is not automatically bad.
Frameworks sometimes use deep hierarchies.
But in application code, prefer shallow hierarchies unless deeper structure is truly justified.
Composition can often replace deep inheritance with clearer collaborator objects.
Rule of thumb:
if understanding a class requires reading five ancestors, the design may be too tangled
Inheritance creates coupling between parent and child.
The child depends on:
- Parent method names.
- Parent initialization.
- Parent invariants.
- Parent attribute choices.
- Parent extension points.
Example:
class User:
def __init__(self, name):
self.name = nameChild:
class AdminUser(User):
def label(self):
return self.name.upper()If User changes internal storage:
self._name = nameand no public name property exists, AdminUser may break.
Inheritance makes internals more tempting to rely on.
This is why encapsulation matters even inside class hierarchies.
Design parent classes with subclasses in mind.
A base class is fragile when small changes in it unexpectedly break subclasses.
Example:
class Base:
def process(self):
self.prepare()
self.run()
def prepare(self):
pass
def run(self):
passSubclasses may override prepare or run.
If Base.process changes the order:
def process(self):
self.run()
self.prepare()subclasses may break.
This does not mean inheritance is bad.
It means base classes define contracts.
If subclasses rely on a method being called at a certain time, that behavior is part of the contract.
Inheritance requires careful API design.
Parent classes are not just code-sharing containers.
They are extension points.
Sometimes a parent class defines an algorithm and lets subclasses customize steps.
Example:
class Importer:
def run(self):
data = self.load()
cleaned = self.clean(data)
self.save(cleaned)
def load(self):
raise NotImplementedError
def clean(self, data):
return data
def save(self, data):
raise NotImplementedErrorSubclass:
class CsvImporter(Importer):
def load(self):
return [" raw "]
def clean(self, data):
return [item.strip() for item in data]
def save(self, data):
print(data)Use:
CsvImporter().run()This is called the template method pattern.
The base class controls the workflow.
Subclasses override specific steps.
This can be elegant when the workflow is stable.
It can be brittle if the workflow changes often.
When overriding a method, keep the meaning compatible.
Parent:
class Repository:
def get(self, key):
return NoneChild should not do something surprising:
class EmailRepository(Repository):
def get(self, key):
send_email("someone@example.com")
return "sent"The method name get suggests retrieval.
Sending email is surprising.
Overridden methods should respect the parent method's purpose.
This is part of substitutability:
code expecting the parent should not be shocked by the child
We will revisit this when studying polymorphism, SOLID, and design principles.
For now:
override to specialize, not to betray the interface
Subclasses often need compatible construction.
Example:
class User:
def __init__(self, name):
self.name = nameSubclass:
class AdminUser(User):
def __init__(self, name, permissions):
super().__init__(name)
self.permissions = permissionsThis is fine when callers know they are creating an AdminUser.
But if code wants to create objects generically, constructor differences can matter.
Example:
def create_user(user_class, name):
return user_class(name)This works for User.
It fails for AdminUser because permissions are required.
Constructor compatibility is a design choice.
Sometimes subclasses require more information.
Sometimes they should provide defaults.
Be aware that construction is part of a class's interface.
When you create a parent class, you create an API for subclasses.
Example:
class BaseView:
def render(self):
context = self.get_context()
return self.render_template(context)
def get_context(self):
return {}
def render_template(self, context):
raise NotImplementedErrorSubclass authors need to know:
- Which methods to override.
- Which methods not to override.
- Which attributes are available.
- Which methods call which hooks.
- What each method must return.
This should be documented or obvious.
Inheritance without clear extension points becomes guesswork.
If you do not want subclasses to depend on internals, do not make internals the only way to extend behavior.
Bad:
class JsonTools:
def to_json(self, data):
return str(data)
class UserService(JsonTools):
passThis says:
UserService is a JsonTools
That is probably not meaningful.
Better:
class UserService:
def __init__(self, json_tools):
self._json_tools = json_toolsor simply use a function:
to_json(data)Inheritance should model a type relationship.
If you only want a helper method, composition or a module function is often clearer.
Parent:
class User:
def __init__(self, name):
self.name = nameChild:
class AdminUser(User):
def __init__(self, name, permissions):
self.permissions = permissionsUse:
admin = AdminUser("Ada", ["manage"])
print(admin.name)This raises:
AttributeError
because User.__init__ never ran.
Correct:
class AdminUser(User):
def __init__(self, name, permissions):
super().__init__(name)
self.permissions = permissionsWhen overriding initialization, ask:
does the parent initializer need to run?
Often, yes.
You may see:
class AdminUser(User):
def __init__(self, name, permissions):
User.__init__(self, name)
self.permissions = permissionsThis can work in simple single inheritance.
But super() is generally preferred:
class AdminUser(User):
def __init__(self, name, permissions):
super().__init__(name)
self.permissions = permissionsWhy?
Because super() cooperates with MRO, especially in multiple inheritance.
Direct parent calls hard-code one parent.
They can break cooperative inheritance patterns.
Chapter 48 will explain this deeply.
For now:
prefer super() when extending inherited methods
Parent:
class Storage:
def save(self, item):
"""Save item and return its id."""
return 1Child:
class BrokenStorage(Storage):
def save(self, item):
print("saved")The child returns None.
If callers expect an id:
item_id = storage.save(item)the child breaks the expectation.
When overriding, preserve the parent method's contract unless you are intentionally creating a different interface.
Compatible overriding includes:
- Similar meaning.
- Compatible parameters.
- Compatible return behavior.
- Compatible exceptions.
- Compatible side effects.
This is design discipline.
Bad early design:
BaseThing
NamedThing
ActiveNamedThing
PersistentActiveNamedThing
User
Maybe each layer seemed reusable.
But now understanding User requires reading many ancestors.
Prefer flatter designs until real patterns emerge.
Composition can often replace intermediate layers:
User has Persistence
User has Status
User has Profile
Inheritance should clarify.
If it creates archaeology, reconsider.
Example:
class User:
pass
class AdminUser(User):
passThis may be fine if admin users truly have different behavior.
But if admin is just a role value:
class User:
def __init__(self, name, role):
self.name = name
self.role = rolemay be simpler.
Ask:
Does AdminUser need different methods or invariants?
Or is admin just data?
Do not create subclasses for every category if simple data works.
Inheritance is for behavior and type relationships, not every label.
Suppose reports vary by output format.
Inheritance:
class HtmlReport(Report):
...
class PdfReport(Report):
...
class CsvReport(Report):
...Maybe this is fine.
But if only formatting varies, composition may be better:
report = Report(formatter=HtmlFormatter())
report = Report(formatter=CsvFormatter())This is often called the strategy pattern.
Instead of subclassing the whole report, inject a formatter object.
Question:
Is the whole object a different kind?
Or is one behavior varying?
If one behavior varies, composition often wins.
Before using inheritance, ask:
Is the child truly a kind of the parent?
Will isinstance(child, parent) make conceptual sense?
Does the child preserve the parent's method meanings?
Does the child need to override behavior?
Can composition solve this more clearly?
Is the hierarchy likely to stay shallow?
Are extension points clear?
Does parent initialization need to run?
Will subclasses depend on parent internals?
Use inheritance when:
- There is a real is-a relationship.
- Child classes specialize parent behavior.
- Shared behavior belongs naturally in a parent.
- Parent APIs are stable and clear.
- Subclasses can preserve parent contracts.
Prefer composition when:
- You only want to reuse helper behavior.
- The relationship is has-a or uses-a.
- You need swappable behavior.
- You want easier testing with fake collaborators.
- A hierarchy would become deep or awkward.
Inheritance is sharp.
Use it with care.
- Create:
class Animal:
def speak(self):
return "sound"
class Dog(Animal):
passCreate a Dog and call speak.
Explain where Python finds the method.
- Override
speakinDogso it returns:
woof
Explain how method overriding changes lookup.
- Create a
Userclass and anAdminUsersubclass.
Let AdminUser add a permissions attribute.
Use super().__init__.
Explain why calling the parent initializer matters.
- Create an
Employeeclass withannual_pay.
Create a Manager subclass that adds a bonus and overrides annual_pay.
Should Manager call super()?
Why or why not?
- Use
isinstance()andissubclass()with your classes.
Explain the difference between checking an object and checking a class.
- Create a
Shapebase class whoseareamethod raisesNotImplementedError.
Create Rectangle and Circle subclasses that override it.
Loop through shapes and call area.
- Identify whether each relationship should use inheritance or composition:
Car and Engine
Dog and Animal
Report and Formatter
Order and LineItem
AdminUser and User
Service and Database
Explain your choices.
- Rewrite this bad inheritance design using composition:
class EmailSender:
def send_email(self, message):
...
class UserService(EmailSender):
...Why is composition better?
- Create a parent class with a method that returns a value.
Override it in a child class but accidentally return None.
Explain how this can break callers expecting the parent contract.
- In your own words, explain:
inheritance should model specialization, not convenience
Use examples from this chapter.
In this chapter we learned:
- Inheritance lets one class receive behavior from another class.
- A child class inherits from a parent class.
- A subclass is a specialized kind of superclass.
- Attribute lookup searches child classes and then parent classes.
- Method overriding happens when a child defines a method with the same name as a parent method.
- Overriding can replace or extend parent behavior.
super()is used to extend inherited behavior without hard-coding the parent class.- If a child does not define
__init__, it can inherit the parent's initializer. - If a child overrides
__init__, it often needs to callsuper().__init__. - Class attributes can be inherited and overridden.
- Instance attributes are created at runtime, often by inherited initializers.
isinstance()understands inheritance for objects.issubclass()checks class relationships.- All normal Python classes ultimately inherit from
object. - Inheritance should model is-a relationships.
- Composition should model has-a and uses-a relationships.
- Deep or careless inheritance can create brittle designs.
Core model:
class Child(Parent):
...
attribute lookup:
instance
Child
Parent
object
Design model:
is-a relationship -> inheritance
has-a relationship -> composition
override -> child specializes parent behavior
super() -> child extends inherited behavior
Inheritance is not just a way to avoid duplicate code.
It is a way to express that one type is a specialized version of another type.
Next we study MRO and super().
This chapter used a simple parent-child model.
Real Python inheritance can involve multiple classes, multiple levels, and cooperative method calls.
Chapter 48 explains how Python decides where to look next.
We will study:
- What MRO means.
- How to inspect a class's MRO.
- Why method resolution order matters.
- How
super()actually works. - Why
super()is not simply "call my parent." - How multiple inheritance changes lookup.
- How cooperative initialization works.
- Why mixins rely on MRO.
- Common MRO and
super()mistakes.
The transition is direct:
inheritance defines class relationships
MRO defines the lookup path through those relationships
Once MRO is clear, multiple inheritance, mixins, ABCs, and descriptors become much easier to reason about.