Skip to content
Happy Programming Guide
Start learning
Python

Object-Oriented Programming (OOP) in Python

Classes, objects, inheritance and the dunder methods that make Python objects feel built in — plus dataclasses and when a function is the better answer.

A laptop computer on a table

A class is a template; an object is one thing made from it. In Python, object-oriented programming is optional — plenty of excellent Python is plain functions — but classes earn their place when data and the operations on it belong together. This guide covers classes, inheritance, the special methods that make your objects behave like built-in ones, and when not to bother.

A first class#

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

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

    def have_birthday(self):
        self.age += 1


ana = Person("Ana", 34)
print(ana.greet())        # Hello, my name is Ana
ana.have_birthday()
print(ana.age)            # 35

__init__ runs when you create an instance and sets up its data. self is the instance itself, passed automatically — you write it in the definition but not in the call.

Instance and class attributes#

Python
class Dog:
    species = "Canis familiaris"     # shared by every dog

    def __init__(self, name):
        self.name = name             # unique to this dog


a = Dog("Rex")
b = Dog("Bella")

print(a.species, b.species)   # both the same
print(a.name, b.name)         # different

Dunder methods#

These are what make your objects work with Python’s built-in syntax:

Python
class Money:
    def __init__(self, pence):
        self.pence = pence

    def __repr__(self):
        return f"Money({self.pence})"          # for developers

    def __str__(self):
        return f"£{self.pence / 100:.2f}"      # for users

    def __eq__(self, other):
        return isinstance(other, Money) and self.pence == other.pence

    def __hash__(self):
        return hash(self.pence)                # needed if __eq__ is defined

    def __add__(self, other):
        return Money(self.pence + other.pence)

    def __lt__(self, other):
        return self.pence < other.pence


a = Money(1250)
b = Money(399)

print(a)                  # £12.50
print(a + b)              # Money(1649)
print(sorted([a, b]))     # uses __lt__
print(a == Money(1250))   # True

Two rules worth remembering. Define __repr__ on every class — it is what appears in tracebacks and debuggers, and the default is useless. And if you define __eq__, define __hash__ too, or your objects cannot go in a set or be used as dictionary keys.

Other useful ones:

Python
    def __len__(self): ...        # len(obj)
    def __getitem__(self, key): ...  # obj[key]
    def __contains__(self, x): ...   # x in obj
    def __iter__(self): ...          # for x in obj
    def __enter__(self) / __exit__(self, *args): ...   # with obj:

Inheritance#

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

    def speak(self):
        raise NotImplementedError("Subclasses must implement speak()")

    def describe(self):
        return f"{self.name} says {self.speak()}"


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


class Cat(Animal):
    def __init__(self, name, indoor=True):
        super().__init__(name)      # run the parent's setup first
        self.indoor = indoor

    def speak(self):
        return "Meow"


for animal in [Dog("Rex"), Cat("Bella")]:
    print(animal.describe())

The loop is polymorphism: the same call produces different behaviour depending on the actual class. Note that describe() was written once and works for both.

Composition is usually better#

Inheritance says “is a”. Composition says “has a”, and it is the right answer more often than beginners expect:

Python
class Engine:
    def start(self):
        return "running"


class Car:
    def __init__(self):
        self.engine = Engine()      # a car HAS an engine

    def start(self):
        return self.engine.start()

Deep inheritance hierarchies become hard to follow, because to understand one class you have to read four. A shallow hierarchy with composition inside is easier to change.

The underscore conventions#

Python
class Account:
    def __init__(self, balance):
        self.balance = balance       # public
        self._rate = 0.02            # internal, by convention
        self.__secret = "hidden"     # name-mangled

    def _recalculate(self):          # internal method
        pass

Python has no real private members. A single underscore is a convention meaning “internal, do not rely on this”. A double underscore triggers name mangling, which prevents accidental collisions in subclasses but is not security. One underscore is what you want almost always.

Properties#

Python
class Temperature:
    def __init__(self, celsius=0):
        self.celsius = celsius        # goes through the setter

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32


t = Temperature(20)
print(t.fahrenheit)     # 68.0 - looks like an attribute
t.celsius = -300        # ValueError

Properties let you add validation or computation later without changing any code that uses the class. That is why Python does not need getters and setters everywhere from the start — start with a plain attribute and convert it if you ever need to.

Dataclasses#

Python
from dataclasses import dataclass, field


@dataclass
class Point:
    x: float
    y: float
    tags: list = field(default_factory=list)

    def distance_from_origin(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5


p = Point(3, 4)
print(p)                       # Point(x=3, y=4, tags=[])
print(p == Point(3, 4))        # True
print(p.distance_from_origin())  # 5.0

The decorator generates __init__, __repr__ and __eq__ for you. Use field(default_factory=list) for mutable defaults — a bare [] raises an error, which is the language protecting you from the shared-list bug above.

When not to use a class#

Python
# Not a class
class Calculator:
    def add(self, a, b):
        return a + b


# Just a function
def add(a, b):
    return a + b

A class with no state and one method is a function wearing a costume. Reach for a class when there is data that several operations share, when you need several independent instances, or when different types should respond to the same call differently. Otherwise a function is clearer.

Questions people ask#

Why does every method need self?

Because Python passes the instance explicitly rather than making it implicit. It is more verbose than other languages and considerably less magical — self.x always clearly means “this instance’s x”.

What is the difference between __str__ and __repr__?

__str__ is for people using your program; __repr__ is for developers debugging it. If you only write one, write __repr__ — Python falls back to it when __str__ is absent.

Should I use dataclasses everywhere?

For classes that mainly hold data, yes — they remove a lot of boilerplate. For classes with substantial behaviour and little state, a plain class is fine.

Do I need to inherit from object?

No. In Python 3 every class does automatically. Writing class Foo(object): is a Python 2 habit.

Where to go next#

Object-oriented programming, explained without the jargonRead next

Keep reading

Python

Python Best Practices

Naming, structure, error handling, virtual environments, type hints and testing — the habits that make Python code easy to come back to.

5 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *