Object-Oriented Programming in Python

The article explains the transition from procedural to object-oriented programming in Python, detailing concepts such as classes, instances, methods, inheritance, and error handling to enhance code organisation and functionality.

There comes a point in every Python learner’s journey where procedural code stops scaling. You have variables holding data over here, functions operating on that data over there, and nothing enforcing the relationship between them. When fifty functions all touch “employee” data, the codebase turns to chaos. Object-oriented programming flips the arrangement: instead of data here and functions there, you bundle them into objects that carry their own data and know how to operate on it. An employee object is not just a dict of fields; it is a self-contained thing that knows how to give itself a raise, compute its monthly pay, and validate its own salary.

The payoff comes in two forms. Organization: related code groups itself naturally when entities own their behavior. And modeling: customers, orders, files, and sensors are objects in the real world, so letting your code mirror that structure makes it easier to reason about. This article walks the whole landscape: classes, attributes, methods, inheritance, dunder methods, and custom exceptions.

Classes Are Cookie Cutters, Instances Are Cookies

The core distinction in OOP is between a class and an instance. The class is the cookie cutter: it defines the shape. Instances are the cookies: separate physical objects made from the same template. Two cookies from the same cutter are identical in shape but you can eat one without affecting the other.

In code, Employee is the cutter. Employee("Maya", 50000) stamps out one cookie; call it again with different arguments and you get another, completely independent, with its own data.

Before building anything, it is worth knowing that everything in Python is already an object, even a humble float, and you can X-ray any object with dir():

ratio = 12 / 8
print(dir(ratio))

The output is a long list of names. The ones wrapped in double underscores, like __add__, are built-in machinery: __add__ is literally what makes 1.5 + 2 work, because Python translates the + operator into a method call. The plain names, like is_integer, are the user-facing API you can call directly. And a single leading underscore, by convention, means “internal, please don’t touch.” Python does not enforce private access the way Java does; it communicates intent through naming conventions, and good Python programmers respect them.

Defining a Class and Meeting self

The minimal class is two lines:

class Employee:
pass
emp = Employee()

pass is just a placeholder because Python refuses an empty body. The interesting part is the last line: classes are callable, and calling one produces a fresh instance.

Methods make the instance useful, and they introduce the single most confusing word in Python OOP:

class Employee:
def set_name(self, new_name):
self.name = new_name
emp = Employee()
emp.set_name("Maya Chen")
print(emp.name)

Every method’s first parameter is self, a reference to the specific instance the method is being called on. The thing to internalize is that you never pass it yourself. When you write emp.set_name("Maya Chen"), Python translates it into Employee.set_name(emp, "Maya Chen") behind the scenes, filling in emp as self. Then self.name = new_name attaches an attribute to that particular instance. A second employee created afterwards would have no name at all until someone set it, because every instance is independent.

That independence exposes the weakness of the setter style. Forget to call set_salary and then read emp.salary, and you get an AttributeError at the worst possible moment. Which is exactly the problem the constructor solves.

The Constructor: One Place to Build a Valid Object

__init__ is a special method Python calls automatically every time you create an instance. It is your one guaranteed opportunity to set up the object’s initial state.

class Employee:
def __init__(self, name, salary=0):
self.name = name
self.salary = salary
def give_raise(self, amount):
self.salary += amount
def monthly_salary(self):
return self.salary / 12
emp = Employee("Maya Chen", 50000)
emp.give_raise(1500)
print(emp.salary) # 51500

When you write Employee("Maya Chen", 50000), Python creates a fresh empty instance, calls __init__ on it with your arguments, and hands back the initialized object. From the moment it exists, the instance has all its required attributes, so the whole class of forgot-to-call-the-setter bugs evaporates. Default arguments work exactly as they do in regular functions, so salary=0 covers callers who do not provide one.

Notice what give_raise does: it reads the current salary off the instance, adds, and writes back. It does not take the salary as a parameter, because the salary lives on self. This is where OOP starts to feel different from procedural code: the method owns its data.

The constructor is also the natural home for validation. The gentle version clamps bad input and prints a warning:

def __init__(self, name, salary=0):
self.name = name
if salary > 0:
self.salary = salary
else:
self.salary = 0
print("Invalid salary!")

This works, but be honest about its weakness: the caller has no idea their data was silently sanitized, and silent fixes mask bugs upstream. The grown-up version raises an exception instead, which we will get to in the final section. The principle either way is the same: never let an invalid object exist.

Class Attributes vs Instance Attributes

There are two places an attribute can live, and confusing them produces some of Python’s most surprising behavior.

class Robot:
MAX_CHARGE = 10
def __init__(self, charge):
if charge <= Robot.MAX_CHARGE:
self.charge = charge
else:
self.charge = Robot.MAX_CHARGE

self.charge is an instance attribute: every robot carries its own. MAX_CHARGE is a class attribute: defined in the class body, outside any method, with no self, and shared by every instance. Use class attributes for constants and shared defaults, instance attributes for per-object state, and signal constants with the ALL_CAPS naming convention.

Now the surprise:

r1 = Robot(9)
r2 = Robot(5)
r1.MAX_CHARGE = 7
print(r1.MAX_CHARGE) # 7
print(r2.MAX_CHARGE) # 10
print(Robot.MAX_CHARGE) # 10

Reading r1.MAX_CHARGE first checks whether r1 has its own attribute by that name, and falls through to the class if not. But writing r1.MAX_CHARGE = 7 does not modify the class. It creates a brand new instance attribute on r1 that shadows the class attribute from then on. Every other instance, and the class itself, still sees 10. The mental model worth memorizing: reads fall through to the class, writes always land on the instance. To change the value for everyone, assign to the class: Robot.MAX_CHARGE = 7.

Class Methods: Alternative Constructors

A regular method receives an instance via self. A class method, marked with the @classmethod decorator, receives the class itself via cls, and its killer use case is alternative constructors.

class Person:
CURRENT_YEAR = 2026
def __init__(self, name, age):
self.name = name
self.age = age
@classmethod
def from_birth_year(cls, name, birth_year):
age = Person.CURRENT_YEAR - birth_year
return cls(name, age)
nadia = Person.from_birth_year("Nadia", 1990)
print(nadia.age) # 36

Sometimes data arrives in a shape that does not match what __init__ expects, a birth year instead of an age, a string instead of parsed integers. A class method translates the incoming format and then calls the real constructor. The pattern shines with parsing:

class CalendarDate:
def __init__(self, year, month, day):
self.year, self.month, self.day = year, month, day
@classmethod
def from_str(cls, datestr):
parts = datestr.split("-")
return cls(int(parts[0]), int(parts[1]), int(parts[2]))
release = CalendarDate.from_str("2026-12-25")

The class now accepts two input formats while __init__ stays focused on the canonical one, and you can add from_dictfrom_json, or from_csv_row as your data sources multiply.

One subtlety repays attention: the body ends with return cls(...), not return CalendarDate(...). If a subclass inherits this method and calls it, cls will be the subclass, so the right kind of object comes back. Hardcoding the parent class name would lock the constructor to the parent forever.

For completeness, there is a third flavor: @staticmethod, which receives neither self nor cls. It is just a function that lives inside the class for organizational reasons, and in practice it is rare; most static-method candidates work just as well as plain module-level functions.

Inheritance: Specialize Without Repeating Yourself

Inheritance lets a subclass automatically acquire everything its parent has. The syntax is the parent’s name in parentheses:

class Employee:
MIN_SALARY = 30000
def __init__(self, name, salary=30000):
self.name = name
self.salary = max(salary, Employee.MIN_SALARY)
def give_raise(self, amount):
self.salary += amount
class Manager(Employee):
pass
boss = Manager("Lena Fischer", 86500)
boss.give_raise(2500)
print(boss.salary) # 89000

Manager has an empty body, yet it works completely, because Python’s attribute lookup falls through to the parent: call Manager(...) and Python looks for __init__ on Manager, fails to find one, and runs Employee’s instead. The mental model is IS-A: a Manager is an Employee, with everything an Employee has, plus eventually its own specializations.

Those specializations usually start with the constructor:

class Manager(Employee):
def __init__(self, name, salary=50000, project=None):
super().__init__(name, salary)
self.project = project
def give_raise(self, amount, bonus=1.05):
super().give_raise(amount * bonus)
boss = Manager("Lena Fischer", 78500)
boss.give_raise(2000, bonus=1.03)
print(boss.salary) # 80560.0

Two important patterns live in this small class. First, super().__init__(name, salary) calls the parent’s constructor to do the shared setup, then the child adds its own project attribute on top. You will also see the older explicit form, Employee.__init__(self, name, salary), which works but hardcodes the parent’s name; super() is refactor-safe, handles multiple inheritance correctly, and is what new code should use.

Second, give_raise demonstrates the extend-don’t-duplicate pattern, which is inheritance at its best. The Manager version adds the bonus multiplication, then delegates the actual salary update to the parent. If the parent’s give_raise ever gains logging or validation, the Manager inherits the improvement for free. Beware the recursion trap here: writing self.give_raise(new_amount) inside Manager’s own give_raise would call itself forever. Delegating upward requires super().

Class attributes override just like methods. A subclass that declares MAX_CHARGE = 15 gives all its instances the new value while the parent’s instances keep the old one, which is also the reason self.MAX_CHARGE inside a method is often preferable to the hardcoded Robot.MAX_CHARGE: the self form follows subclass overrides automatically.

Dunder Methods: Plugging Into Python’s Syntax

Dunder methods (double underscore, sometimes called magic methods) are how your objects integrate with Python’s operators and built-ins. Want obj == other to work meaningfully? Define __eq__. Want len(obj)? Define __len__. Each piece of syntax maps to a method your class can implement.

Equality is the one you will need first. By default, == on custom objects checks identity, meaning two distinct objects never compare equal even with identical contents:

class BankAccount:
def __init__(self, number, balance=0):
self.number = number
self.balance = balance
def withdraw(self, amount):
self.balance -= amount
def __eq__(self, other):
return (self.number == other.number) and (type(self) == type(other))
acct1 = BankAccount(123, 1000)
acct2 = BankAccount(123, 500)
print(acct1 == acct2) # True — same account number

When you write acct1 == acct2, Python calls acct1.__eq__(acct2), with the right-hand operand arriving as other. Here two accounts are equal when their numbers match, regardless of balance, capturing the semantic “these represent the same real-world account.” The type(self) == type(other) check guards against an embarrassing bug: without it, a BankAccount and a Phone that happen to share the same number would compare equal. If you want subclass instances to be able to equal their parents, swap the strict type check for isinstance; for value-like classes, strict is safer.

Then there are the two string representations, which trip everyone up exactly once:

class Employee:
def __init__(self, name, salary=30000):
self.name, self.salary = name, salary
def __repr__(self):
return f"Employee({self.name!r}, {self.salary})"
def __str__(self):
return f"Employee name: {self.name}\nEmployee salary: {self.salary}"

__repr__ is for developers: it appears in the REPL, in debuggers, and when printing containers of objects. The convention is to produce a string that looks like the constructor call, ideally pasteable back into Python to recreate the object. The !rconversion in the f-string auto-quotes the name and handles edge cases like names containing quotes. __str__ is for humans: it is what print(obj) produces, and it should read like English.

If you define only one, make it __repr__, because Python falls back to it when __str__ is missing. Define only __str__ and your debugger output degrades to the useless <Employee object at 0x10c4f2d50>.

The wider dunder landscape follows the same logic: __len__ makes len(obj) work, __getitem__ makes obj[key] work, __contains__ powers in,

 __add__ powers +, __hash__ lets objects live in sets and dict keys, and __call__ makes an instance callable like a function. This is Python’s data model: rather than a fixed list of blessed container types, anything implementing the right dunders is a container. Use the power judiciously, though. Overload operators only when the meaning is obvious, like vectors adding with +. Clever operator abuse reads as noise.

Exceptions: Failing Loudly and Specifically

A quick refresher on the mechanics. try/except lets code attempt risky operations and recover, and one function can guard against multiple failure modes:

def reciprocal_at(values, index):
try:
return 1 / values[index]
except ZeroDivisionError:
print("Cannot divide by zero!")
except IndexError:
print("Index out of range!")

Python checks except clauses top-down and runs the first match, so specific exceptions must come before general ones; an except Exception placed first would swallow everything and make the handlers below unreachable. When several types deserve identical handling, group them in a tuple, and bind the exception object with as e so your logs capture the details: except (ZeroDivisionError, IndexError) as e.

The interesting move is defining your own exceptions, which takes two lines each:

class SalaryError(ValueError):
pass
class BonusError(SalaryError):
pass

SalaryError(ValueError) reads as “a SalaryError IS a ValueError”: it slots into Python’s existing hierarchy, so any code catching ValueError will catch it too, but its name documents exactly what went wrong. BonusError specializes further. The hierarchy gives callers a choice of granularity: catch BonusError to handle bonus problems specially, SalaryError to handle any salary issue, or ValueError to catch every value-shaped problem in one net. Choosing the right built-in parent matters: a salary that is too low is the right type with a wrong value, hence ValueError; an argument of the wrong type entirely would inherit from TypeError.

Now the constructor validation can be done properly:

class Employee:
MIN_SALARY = 30000
MAX_BONUS = 5000
def __init__(self, name, salary=30000):
self.name = name
if salary < Employee.MIN_SALARY:
raise SalaryError("Salary is too low!")
self.salary = salary
def give_bonus(self, amount):
if amount > Employee.MAX_BONUS:
raise BonusError("The bonus amount is too high!")
elif self.salary + amount < Employee.MIN_SALARY:
raise SalaryError("The salary after bonus is too low!")
self.salary += amount

This is fail-fast design. If the salary is invalid, the raise halts the constructor before self.salary is ever assigned, so no invalid Employee can ever exist. Compare that to the clamp-and-print version from earlier, where the caller might never notice their input was rejected and proceed on a false assumption.

give_bonus adds a second discipline: check before mutating. Both validations run before self.salary += amount, so a rejected bonus leaves the object’s state completely untouched. Reverse the order, mutate then validate, and a failure would leave a half-applied transaction behind, which is the kind of inconsistency that takes days to track down.

The underlying principle ties it all together: errors should be impossible to ignore unless explicitly handled. Printing a warning and returning None is the worst pattern, because it looks like success, hands the caller a None they will treat as a real value, and surfaces as a mysterious bug five steps later. Raise at validation boundaries, catch at recovery points, and save printing for genuinely informational messages.

A Mental Model for Designing Classes

When you sit down to design a class, the questions come in a natural order. What state does the entity carry? Those become instance attributes set in __init__. What constants apply to all instances? Class attributes in ALL_CAPS. What can it do? Methods, named as verbs. Are there alternative ways to construct it? Class methods like from_str. How should it print? Always __repr__, plus __str__ when users will read it. How does it compare? __eq__, plus __hash__ if it needs to live in sets. Can it fail? Custom exceptions. Does a specialized version exist? A subclass.

And before reaching for inheritance, apply the IS-A versus HAS-A test. “A Manager is an Employee” passes IS-A, so inheritance fits. “An Order has a Customer” is HAS-A, so the order should simply hold a customer object as an attribute, which is composition. The most common newcomer mistake is over-using inheritance, building deep fragile hierarchies where composition would be cleaner and looser. The modern wisdom is to prefer composition and reserve inheritance for genuine behavioral hierarchies.

Quick Reference

TaskCode
Constructordef __init__(self, x):
Instance attributeself.x = x
Class attributeCONSTANT = 10 in the class body
Class method@classmethod + def cm(cls, ...):
Inheritclass Sub(Parent):
Call parent methodsuper().method(...)
Custom equalitydef __eq__(self, other):
Developer stringdef __repr__(self):
User stringdef __str__(self):
Custom exceptionclass MyError(ValueError): pass
Raiseraise MyError("msg")
Inspect an objectdir(obj)

Master these pieces and most Python codebases stop being intimidating, because frameworks from Django models to pandas DataFrames are built from exactly this vocabulary: classes with constructors, inherited behavior, dunder integrations, and exceptions that fail fast. You now speak it.

See you soon.

View Comments (1)

Leave a Reply

Prev Next

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Discuss Data Science, Machine Learning and Analytics

Subscribe now to keep reading and get access to the full archive.

Continue reading