
Everything built so far in this series has kept data and behavior separate: a dictionary holds a person’s information, and a separate function operates on it. That works fine for one person, one conversion, one calculation at a time. It starts to strain the moment you need many independent things of the same kind — a hundred employees, each with their own salary and their own bonus calculation; a dozen bank accounts, each with its own balance and its own transaction history — where the data and the logic that belongs with it need to travel together, per instance, without getting tangled.
This is exactly what classes solve. A class bundles data (called attributes) and the functions that operate on that data (called methods) into a single, reusable blueprint. Every object you create from that blueprint — every instance — has its own independent copy of the data, while sharing the same behavior.
This post covers classes from first principles: defining one, creating instances, inheritance for sharing behavior between related classes, and — just as importantly — when a class is genuinely the right tool versus when a plain function or dictionary would serve you better.
The Mental Model: Blueprint and Instance
A class is a blueprint. An instance is one specific object built from that blueprint. Think of a cookie cutter: the cutter itself defines the shape every cookie will have, but each actual cookie that comes out of it is a separate piece of dough — you can put sprinkles on one and leave another plain, and changing one cookie does not affect any other.
class Employee: defines the shape — what data every employee has, and what an employee can do. Employee("Alex", 75000) produces one specific instance — a real employee with a real name and salary, entirely independent of any other Employee instance you create from the same class.
Defining Your First Class
class Employee:
def __init__(self, name: str, salary: float):
self.name = name
self.salary = salary
def annual_bonus(self) -> float:
return self.salary * 0.05
def __repr__(self) -> str:
return f"Employee(name={self.name!r}, salary={self.salary})"
Breaking this down:
class Employee: — starts the class definition, Employee is the class name (Python convention: class names use PascalCase, unlike the snake_case used for functions and variables).
def __init__(self, name, salary): — the constructor, a special method Python calls automatically every time you create a new instance. Its job is to set up the instance’s initial data.
self — refers to the specific instance being created or operated on. It is the first parameter of every method, by convention always named self, and Python passes it automatically — you never provide it explicitly when calling a method.
self.name = name — stores the name argument as an attribute on this specific instance, so it can be accessed later through self.name in other methods, or employee.name from outside the class entirely.
def annual_bonus(self): — a regular method, using self to access this instance’s own data.
def __repr__(self): — a special “dunder” (double-underscore) method controlling how an instance displays when printed or inspected — covered further below.
Creating Instances
alex = Employee("Alex", 75000)
sam = Employee("Sam", 82000)
print(alex.name) # Alex
print(alex.annual_bonus()) # 3750.0
print(sam.annual_bonus()) # 4100.0
print(alex) # Employee(name='Alex', salary=75000)
alex and sam are two completely independent Employee instances. Changing alex.salary has no effect whatsoever on sam — this is the entire point of bundling data into instances rather than tracking everyone’s salary in a single shared dictionary.
alex.salary = 80000 # a raise!
print(alex.annual_bonus()) # 4000.0
print(sam.annual_bonus()) # 4100.0 — completely unaffected
__repr__ and __str__: Controlling How Instances Display
Without __repr__ defined, printing an instance produces an unhelpful default:
class PlainEmployee:
def __init__(self, name):
self.name = name
p = PlainEmployee("Alex")
print(p) # <__main__.PlainEmployee object at 0x104f3a250>
That memory address tells you nothing useful. Defining __repr__ — as the Employee class above does — gives you a readable, debuggable representation instead, and it is one of the first methods worth adding to almost any class you write:
print(alex) # Employee(name='Alex', salary=75000)
The convention is that __repr__ should ideally look like valid Python code that could recreate the object — which is why the format above matches the actual constructor call, Employee(name='Alex', salary=75000).
Class Attributes vs. Instance Attributes — a Critical Distinction
class Employee:
company = "Acme Corp" # class attribute — shared by every instance
def __init__(self, name: str, salary: float):
self.name = name # instance attribute — unique per instance
self.salary = salary # instance attribute — unique per instance
alex = Employee("Alex", 75000)
sam = Employee("Sam", 82000)
print(alex.company) # Acme Corp
print(sam.company) # Acme Corp — same value, shared from the class
A class attribute — defined directly inside the class body, not inside __init__ — is shared by every instance of that class. This is appropriate for genuinely shared, unchanging data (a company name every employee shares). It is a serious mistake for anything mutable.
⚠️ The Mutable Class Attribute Trap
This is the class-based sibling of Post #4’s mutable default argument bug, and it catches people the same way:
# DANGEROUS
class ShoppingCart:
items = [] # class attribute — shared across every instance!
def add_item(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add_item("apple")
print(cart2.items) # ['apple'] — cart2 sees cart1's item!
Both carts are silently sharing the exact same list, because items = [] was created once, when the class was defined — not fresh for each instance. The fix mirrors Post #4’s fix precisely: any mutable data that should be independent per instance belongs inside __init__, assigned to self, not as a bare class attribute.
# Correct
class ShoppingCart:
def __init__(self):
self.items = [] # created fresh, independently, for every instance
def add_item(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add_item("apple")
print(cart2.items) # [] — correctly independent
Rule of thumb: class attributes are for genuinely shared, immutable data (constants, defaults that never change). Anything mutable — lists, dictionaries, sets — belongs inside __init__ as an instance attribute, no exceptions.
Inheritance: Sharing Behavior Between Related Classes
Inheritance lets one class (a subclass) build on another (a parent, or “base,” class) — inheriting its attributes and methods, and optionally overriding or extending them.
class Employee:
def __init__(self, name: str, salary: float):
self.name = name
self.salary = salary
def annual_bonus(self) -> float:
return self.salary * 0.05
def __repr__(self) -> str:
return f"Employee(name={self.name!r}, salary={self.salary})"
class Manager(Employee):
def __init__(self, name: str, salary: float, team_size: int):
super().__init__(name, salary) # run Employee's __init__ first
self.team_size = team_size
def annual_bonus(self) -> float:
base_bonus = super().annual_bonus() # reuse Employee's calculation
return base_bonus + (self.team_size * 500)
def __repr__(self) -> str:
return f"Manager(name={self.name!r}, salary={self.salary}, team_size={self.team_size})"
alex = Employee("Alex", 75000)
priya = Manager("Priya", 95000, team_size=6)
print(alex.annual_bonus()) # 3750.0
print(priya.annual_bonus()) # 7750.0 — base 5% plus 500 per team member
print(isinstance(priya, Employee)) # True — a Manager IS an Employee
class Manager(Employee): declares that Manager inherits from Employee. super().__init__(name, salary) calls the parent class’s constructor to handle the setup Manager shares with every Employee, before adding its own additional attribute (team_size). super().annual_bonus() reuses the parent’s bonus calculation rather than duplicating that 5% logic, then adds the manager-specific bonus on top.
The relationship this models: every Manager genuinely is an Employee — with everything an employee has, plus something extra. This “is-a” relationship is the litmus test for whether inheritance is the right tool: if you cannot honestly say “a Manager is a kind of Employee,” inheritance is probably the wrong choice, even if some code happens to overlap.
Composition: The Often-Better Alternative
Inheritance is powerful, but it is frequently overused by beginners reaching for it as the default way to reuse code. Composition — building a class that contains an instance of another class, rather than inheriting from it — is often the simpler, more flexible choice.
class Engine:
def __init__(self, horsepower: int):
self.horsepower = horsepower
def start(self) -> str:
return f"Engine starting: {self.horsepower}hp"
class Car:
def __init__(self, model: str, horsepower: int):
self.model = model
self.engine = Engine(horsepower) # composition — Car HAS an Engine
def start(self) -> str:
return f"{self.model}: {self.engine.start()}"
my_car = Car("Sedan", 180)
print(my_car.start()) # Sedan: Engine starting: 180hp
A Car is not a kind of Engine — it has one. Modeling this with composition (Car holding an Engine instance) rather than inheritance (Car extending Engine) matches the real relationship far more honestly, and it keeps the two classes independently testable and reusable — you could swap in an ElectricEngine without touching Car’s definition at all. The general guidance experienced developers follow: favor composition over inheritance whenever the relationship is genuinely “has-a” rather than “is-a” — a principle Post #18 on design patterns returns to in depth.
Refactoring the Unit Converter With a Class
The dictionary-based dispatch from Post #5 works well and does not need a class to function correctly — but if you wanted to add state that persists across conversions, like tracking history, a class becomes the natural fit:
class UnitConverter:
def __init__(self):
self.history: list[tuple[str, float, float]] = []
self.conversions = {
"1": ("Miles to Kilometers", self._miles_to_km),
"2": ("Kilometers to Miles", self._km_to_miles),
"3": ("Fahrenheit to Celsius", self._f_to_c),
"4": ("Celsius to Fahrenheit", self._c_to_f),
}
def _miles_to_km(self, miles: float) -> float:
return miles * 1.60934
def _km_to_miles(self, km: float) -> float:
return km / 1.60934
def _f_to_c(self, f: float) -> float:
return (f - 32) * 5 / 9
def _c_to_f(self, c: float) -> float:
return (c * 9 / 5) + 32
def convert(self, choice: str, value: float) -> float | None:
if choice not in self.conversions:
return None
label, func = self.conversions[choice]
result = func(value)
self.history.append((label, value, result))
return result
def show_history(self) -> None:
if not self.history:
print("No conversions yet.")
return
for label, original, result in self.history:
print(f" {original} → {result:.2f} ({label})")
def main():
converter = UnitConverter()
while True:
print("\n=== Unit Converter ===")
for key, (label, _) in converter.conversions.items():
print(f"{key}. {label}")
print("5. Show history")
print("6. Quit")
choice = input("Choose an option: ")
if choice == "6":
print("Goodbye!")
break
if choice == "5":
converter.show_history()
continue
value = float(input("Enter the value to convert: "))
result = converter.convert(choice, value)
if result is None:
print("Invalid choice.")
else:
print(f"{value} → {result:.2f}")
if __name__ == "__main__":
main()
Every UnitConverter instance now carries its own conversion history independently — exactly the “bundled data plus behavior” that classes exist for. Note the leading underscore on _miles_to_km and the other conversion methods: this is a Python convention (not an enforced rule) signaling “this is an internal implementation detail, not part of the class’s intended public interface” — external code is expected to call .convert(), not the underscore-prefixed methods directly.
Real-World Use Cases
Modeling entities with both data and behavior: Users, orders, game characters, database records — anything with attributes that change over time and behavior specific to that data is a natural class candidate.
Building on shared behavior across related types: A base Shape class with area() and perimeter() methods, with Circle and Rectangle subclasses overriding the specific calculations, is the canonical inheritance pattern — same interface, different implementation per type.
Wrapping external resources with cleanup logic: Classes representing a database connection, a file handle, or an API client often bundle the connection state with methods for using and properly closing that resource — a pattern that becomes fully idiomatic once context managers appear later in this series.
Frameworks you will use soon: Nearly every web framework, testing library, and data science tool in the Python ecosystem is built around classes you will subclass or instantiate — understanding OOP well here pays off immediately once Post #10 introduces working with external libraries and APIs.
When NOT to Use a Class
Classes are not always the right answer, and reaching for one reflexively is a common overcorrection once developers first learn OOP.
# Overkill — a class with one method and no meaningful state
class Calculator:
def add(self, a, b):
return a + b
# Simpler and equally correct
def add(a, b):
return a + b
If there is no meaningful state to bundle — no data that needs to persist between calls, no independent instances that need separate copies of anything — a plain function is simpler, easier to test, and easier to read. The unit converter’s original dictionary-based dispatch from Post #5 was entirely appropriate without a class; adding one only became worthwhile once history-tracking introduced genuine per-instance state.
The practical test: if you find yourself writing a class where every method is @staticmethod (a decorator, covered in Post #14, for methods that do not use self at all) or where you only ever create one instance total, that is a signal a class may be unnecessary ceremony around what is really just a collection of related functions.
Common Mistakes and Gotchas
⚠️ Mistake 1: Forgetting self as the first parameter
class Broken:
def greet(name): # missing self!
return f"Hello, {name}"
b = Broken()
b.greet() # TypeError: greet() missing 1 required positional argument
Every instance method needs self as its first parameter — Python passes the instance automatically, but the parameter must be there to receive it.
⚠️ Mistake 2: The mutable class attribute trap
Covered in depth above — mutable data (lists, dicts, sets) belongs inside __init__, never as a bare class attribute, for exactly the same reason mutable default arguments are dangerous in plain functions.
⚠️ Mistake 3: Confusing inheritance (“is-a”) with convenience code reuse
Making Manager inherit from Employee is justified because a manager genuinely is a kind of employee. Making a ReportGenerator inherit from DatabaseConnection just to reuse a connect() method, when a report generator is clearly not a kind of database connection, is a misuse of inheritance that composition would handle more honestly.
⚠️ Mistake 4: Forgetting to call super().__init__() in a subclass
class Manager(Employee):
def __init__(self, name, salary, team_size):
self.team_size = team_size # forgot to call super().__init__()!
priya = Manager("Priya", 95000, 6)
print(priya.name) # AttributeError: 'Manager' object has no attribute 'name'
If a subclass defines its own __init__, it must explicitly call super().__init__(...) to run the parent’s setup — Python does not do this automatically once you have overridden the constructor.
⚠️ Mistake 5: Creating a class for something that never needs more than one instance and has no real state Covered above — not every reusable piece of logic needs to be a class. Prefer the simplest tool that solves the actual problem.
Performance Note
Creating and using class instances carries a small memory and attribute-lookup overhead compared to plain dictionaries or functions — each instance stores its own attribute dictionary internally by default. For virtually all application code this overhead is irrelevant next to the organizational and readability benefits classes provide. It becomes a genuine consideration only when creating enormous numbers of small instances in tight loops, a situation where Python’s __slots__ mechanism (worth knowing exists, covered in later advanced material) can reduce that overhead — not something to worry about while these fundamentals are still settling in.
Quick Reference
class ClassName:
class_attribute = "shared by all instances" # use only for immutable, truly shared data
def __init__(self, param1, param2):
self.instance_attr1 = param1 # unique per instance
self.instance_attr2 = param2
def some_method(self):
return self.instance_attr1
def __repr__(self):
return f"ClassName({self.instance_attr1!r})"
# Creating instances
obj = ClassName("value1", "value2")
obj.some_method()
# Inheritance
class SubClass(ClassName):
def __init__(self, param1, param2, extra):
super().__init__(param1, param2) # run parent's setup first
self.extra = extra
def some_method(self): # override
base_result = super().some_method() # reuse parent's version
return base_result + self.extra
# Checking relationships
isinstance(obj, ClassName) # True if obj is a ClassName (or subclass) instance
issubclass(SubClass, ClassName) # True
Exercises
Exercise 1 — Direct application
Write a Rectangle class with width and height attributes, and methods area() and perimeter(). Create two instances with different dimensions and verify their calculations are independent.
Exercise 2 — Slight variation
Create a Square class that inherits from Rectangle, using super().__init__() to set both width and height to the same value from a single side parameter passed to Square.
Exercise 3 — Real-world combination
Write a BankAccount class with a balance attribute (starting at 0), and methods deposit(amount) and withdraw(amount). withdraw should refuse (print a message, do not raise an error yet — that’s Post #7) if the withdrawal would make the balance negative.
Exercise 4 — Open-ended challenge
The UnitConverter class in this post stores history as a list of tuples. Add a method most_common_conversion() that returns which conversion type has been used most often. Hint: you learned exactly the counting pattern you need for this in Post #5’s dictionary section.
FAQ
Q: What’s the actual difference between a class and an instance?
A: A class is the definition — the blueprint describing what attributes and methods every object of this kind will have. An instance is one specific object created from that blueprint, with its own independent copy of the instance attributes. Employee is the class; alex = Employee("Alex", 75000) creates one instance.
Q: Do I need to call methods with self explicitly?
A: No — when you write alex.annual_bonus(), Python automatically passes alex as the self parameter behind the scenes. You only see self explicitly in the method’s own definition, never in the calling code.
Q: Why do some Python method names have double underscores, like __init__ and __repr__?
A: These are called “dunder” (double underscore) methods, and they are Python’s mechanism for hooking into built-in language behavior — __init__ runs on object creation, __repr__ controls how print() and the REPL display an instance, __eq__ would control what == does between two instances of your class, and so on. You will encounter more of these as the series progresses.
Q: Should every related pair of things use inheritance? A: No — only when the relationship genuinely passes the “is-a” test (a Manager is a kind of Employee; a Square is a kind of Rectangle). When the relationship is closer to “has-a” (a Car has an Engine), composition is the more honest and more flexible choice, as covered in this post.
Q: Is it bad to have a lot of small classes? A: Not inherently — many small, focused classes each doing one clear job is generally healthier than a few enormous classes trying to do everything. The concern is the opposite direction: creating a class where a simple function would do, adding ceremony without adding clarity.
Summary and Next Steps
You can now define classes with __init__ constructors and instance methods, understand exactly why mutable class attributes are dangerous (and how to avoid the trap), use inheritance to share and extend behavior between genuinely related classes with super(), and recognize when composition — or simply a plain function — is the better choice instead. The unit converter now has a class-based version capable of tracking conversion history across calls, something the plain dictionary-dispatch version from Post #5 had no natural way to do.
Your next step: Complete Exercise 3 — the BankAccount class — and specifically notice the awkwardness of using print() to reject an invalid withdrawal rather than genuinely stopping the program from proceeding with bad data. That exact awkwardness is what Post #7 resolves properly with real error handling.
Code tested with Python 3.13. Last updated: June 2026.



