Home Python Python Classes and Objects Explained — OOP Patterns That Actually Make Sense

Python Classes and Objects Explained — OOP Patterns That Actually Make Sense

In Plain English 🔥
Imagine a cookie cutter. The cutter itself is the class — it defines the shape, the size, the pattern. Every cookie you stamp out is an object — same blueprint, but each one exists independently and can have different toppings. You don't eat the cutter, you eat the cookies. In Python, a class is your cookie cutter, and every time you call it, you get a fresh cookie (object) to work with.
⚡ Quick Answer
Imagine a cookie cutter. The cutter itself is the class — it defines the shape, the size, the pattern. Every cookie you stamp out is an object — same blueprint, but each one exists independently and can have different toppings. You don't eat the cutter, you eat the cookies. In Python, a class is your cookie cutter, and every time you call it, you get a fresh cookie (object) to work with.

Every serious Python codebase — from Django web apps to machine learning pipelines — is built around classes and objects. Without them, your code grows into one long, tangled script that becomes impossible to maintain past a few hundred lines. Classes let you model the real world in code: a User, a BankAccount, a Product. They bundle data and behavior together so tightly that you can reason about one thing at a time instead of juggling dozens of loose variables and functions.

The problem OOP solves isn't a technical one — it's a human one. Our brains think in terms of things and their behaviors. A dog barks, a bank account accrues interest, a shopping cart holds items. Procedural code fights that instinct by scattering related data across functions and global state. Classes align your code with how you already think, which means fewer bugs, easier testing, and teammates who can actually read what you wrote.

By the end of this article, you'll know exactly how to define a class with meaningful attributes and methods, understand what __init__ is really doing under the hood, recognise when a class is the right tool versus a plain function or dictionary, and avoid the three most common mistakes that trip up developers making the jump to OOP in Python.

What a Class Actually Is (and Why __init__ Isn't a Constructor)

A class is a blueprint that combines state (data) and behaviour (functions) into one named unit. The moment you write class BankAccount:, Python creates a new type — just like int or str are types. When you call BankAccount(), Python creates a new instance of that type and hands it back to you.

Here's the part that trips people up: __init__ is NOT a constructor. The object already exists by the time __init__ runs. Python's actual constructor is __new__, which allocates memory and creates the instance. __init__ is an initialiser — it receives the already-created object (self) and sets its starting values. This distinction matters when you start working with inheritance and metaclasses.

self is just a reference to the specific instance being initialised. When you call account.deposit(100), Python silently rewrites it as BankAccount.deposit(account, 100). There's no magic — self is just the first positional argument, and the name self is a strong convention, not a keyword. Knowing this makes error messages like missing 1 required positional argument: 'self' instantly readable.

bank_account.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
class BankAccount:
    # Class attribute — shared across ALL instances of BankAccount
    interest_rate = 0.03

    def __init__(self, owner_name: str, opening_balance: float = 0.0):
        # Instance attributes — unique to each BankAccount object
        self.owner_name = owner_name
        self.balance = opening_balance
        self._transaction_history = []  # Underscore signals 'treat this as private'

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError(f"Deposit amount must be positive, got {amount}")
        self.balance += amount
        self._transaction_history.append(f"Deposited £{amount:.2f}")

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self._transaction_history.append(f"Withdrew £{amount:.2f}")

    def apply_interest(self) -> None:
        # Accessing the class attribute via self — works, but be aware of the lookup order
        earned = self.balance * BankAccount.interest_rate
        self.balance += earned
        self._transaction_history.append(f"Interest applied: £{earned:.2f}")

    def get_statement(self) -> str:
        lines = [f"Account owner: {self.owner_name}",
                 f"Balance: £{self.balance:.2f}",
                 "Transactions:"]
        lines.extend(f"  - {entry}" for entry in self._transaction_history)
        return "\n".join(lines)


# Creating two INDEPENDENT objects from the same class
alices_account = BankAccount(owner_name="Alice", opening_balance=500.0)
bobs_account = BankAccount(owner_name="Bob", opening_balance=100.0)

alices_account.deposit(250.0)
alices_account.apply_interest()
bobs_account.deposit(50.0)
bobs_account.withdraw(30.0)

print(alices_account.get_statement())
print()
print(bobs_account.get_statement())

# Prove they are independent — changing Bob's balance doesn't touch Alice's
print(f"\nAre they the same object? {alices_account is bobs_account}")
▶ Output
Account owner: Alice
Balance: £773.00
Transactions:
- Deposited £250.00
- Interest applied: £23.00

Account owner: Bob
Balance: £120.00
Transactions:
- Deposited £50.00
- Withdrew £30.00

Are they the same object? False
🔥
Why 'self' is Not a KeywordYou can technically name it anything — `def __init__(this, name)` works fine. But never do it. The Python community reads `self` the same way drivers read road signs — instantly and without thinking. Breaking that convention makes your code feel foreign to every Python developer who opens it.

Instance vs Class vs Static Methods — Choosing the Right Tool

Python gives you three kinds of methods, and picking the wrong one is one of the most common intermediate-level mistakes. The difference isn't just syntactic — each one signals intent to the reader.

An instance method receives self and can read and write the instance's state. Use it whenever the behaviour depends on, or changes, a specific object's data. This is 90% of your methods.

A class method receives cls (the class itself) instead of an instance. Use it for alternative constructors — ways to build an object from different inputs. The canonical example is parsing from a string or a file. You've seen this in the wild: datetime.fromisoformat('2024-01-15') is a class method.

A static method receives neither self nor cls. It's just a regular function that lives inside the class namespace because it logically belongs there. Use it for pure utility functions that relate to the class concept but don't need access to any state. If you find yourself writing a static method that accesses class data, it should probably be a class method.

product.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
class Product:
    # Class attribute tracking how many Product objects exist
    _product_count = 0
    TAX_RATE = 0.20  # 20% VAT — a constant belonging to the Product concept

    def __init__(self, name: str, price_ex_tax: float, category: str):
        self.name = name
        self.price_ex_tax = price_ex_tax
        self.category = category
        Product._product_count += 1  # Increment shared counter on every new instance

    # --- INSTANCE METHOD: needs to read this specific product's price ---
    def price_inc_tax(self) -> float:
        return self.price_ex_tax * (1 + Product.TAX_RATE)

    def describe(self) -> str:
        return (f"{self.name} ({self.category}) — "
                f"£{self.price_ex_tax:.2f} ex VAT / "
                f"£{self.price_inc_tax():.2f} inc VAT")

    # --- CLASS METHOD: alternative constructor — build from a CSV string ---
    @classmethod
    def from_csv_row(cls, csv_string: str) -> "Product":
        # csv_string format: "name,price,category"
        name, price, category = csv_string.strip().split(",")
        return cls(name=name, price_ex_tax=float(price), category=category)

    # --- CLASS METHOD: factory that accesses shared class state ---
    @classmethod
    def total_products_created(cls) -> int:
        return cls._product_count

    # --- STATIC METHOD: pure utility — belongs here logically but needs no state ---
    @staticmethod
    def is_valid_price(price: float) -> bool:
        # A price check doesn't need any Product instance or class data
        return isinstance(price, (int, float)) and price >= 0


# Standard construction
headphones = Product(name="Wireless Headphones", price_ex_tax=79.99, category="Electronics")

# Alternative construction via class method — clean API, no manual parsing by the caller
shirt = Product.from_csv_row("Cotton Shirt,24.99,Clothing")
novel = Product.from_csv_row("The Midnight Library,8.99,Books")

print(headphones.describe())
print(shirt.describe())
print(novel.describe())

print(f"\nTotal products created: {Product.total_products_created()}")

# Static method — call on the class, no instance needed
print(f"\nIs -5.00 a valid price? {Product.is_valid_price(-5.00)}")
print(f"Is 19.99 a valid price? {Product.is_valid_price(19.99)}")
▶ Output
Wireless Headphones (Electronics) — £79.99 ex VAT / £95.99 inc VAT
Cotton Shirt (Clothing) — £24.99 ex VAT / £29.99 inc VAT
The Midnight Library (Books) — £8.99 ex VAT / £10.79 inc VAT

Total products created: 3

Is -5.00 a valid price? False
Is 19.99 a valid price? True
⚠️
Pro Tip: Class Methods as Alternative ConstructorsWhen your class can be built from multiple input formats (JSON, CSV, a database row), use class methods instead of cramming all the parsing logic into __init__. Django's ORM does this extensively — Model.objects.get(), Model.objects.create() are all class-level entry points. It keeps __init__ clean and gives callers a readable, intention-revealing API.

Encapsulation with Properties — Protect State Without Sacrificing Readability

Encapsulation is about controlling how the outside world reads and writes your object's internal data. In Java, you'd write explicit getAge() and setAge() methods. Python's @property decorator gives you the same control with attribute-style access — so callers write employee.salary instead of employee.get_salary(), but you still control what happens when they do.

This matters more than it sounds. Imagine you store a temperature in Celsius internally but need to expose Fahrenheit. Or you store a user's birth date but want .age to compute dynamically. Properties let you add that logic later without breaking any code that already uses your class — that's the Open/Closed principle in action.

The underscore convention (_salary, __password) is Python's way of signalling access intent. Single underscore: 'I'd prefer you didn't touch this directly, but I trust you.' Double underscore: name mangling kicks in — Python renames it to _ClassName__attribute to prevent accidental overrides in subclasses. Neither is truly private, because Python respects adult developers. They're social contracts, not padlocks.

employee.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
class Employee:
    def __init__(self, full_name: str, salary: float, department: str):
        self.full_name = full_name
        self.department = department
        self._salary = None          # Will be set via the property setter below
        self.salary = salary         # Triggers the @salary.setter validation immediately

    # @property turns this method into a readable attribute: employee.salary
    @property
    def salary(self) -> float:
        return self._salary

    # @salary.setter is called when someone writes: employee.salary = 50000
    @salary.setter
    def salary(self, new_salary: float) -> None:
        if not isinstance(new_salary, (int, float)):
            raise TypeError(f"Salary must be numeric, got {type(new_salary).__name__}")
        if new_salary < 0:
            raise ValueError(f"Salary cannot be negative: {new_salary}")
        self._salary = float(new_salary)

    # A computed property — no setter needed, this is read-only
    @property
    def annual_bonus(self) -> float:
        # Senior staff (salary > 60k) get 15%, everyone else gets 8%
        rate = 0.15 if self._salary > 60_000 else 0.08
        return round(self._salary * rate, 2)

    @property
    def display_name(self) -> str:
        # Derive first name from full name — computed on demand, not stored
        return self.full_name.split()[0]

    def __repr__(self) -> str:
        # __repr__ is for developers — shown in the REPL, logs, and debugging
        return (f"Employee(full_name={self.full_name!r}, "
                f"salary={self._salary}, department={self.department!r})")

    def __str__(self) -> str:
        # __str__ is for end users — shown by print()
        return (f"{self.display_name} | {self.department} | "
                f"£{self._salary:,.2f} salary | £{self.annual_bonus:,.2f} bonus")


# Property setter validates on creation — no separate validate() call needed
junior_dev = Employee(full_name="Maria Santos", salary=45_000, department="Engineering")
senior_dev = Employee(full_name="James Okafor", salary=85_000, department="Engineering")

print(junior_dev)
print(senior_dev)

# Property setter validates on update too
junior_dev.salary = 52_000  # Promotion — triggers setter validation
print(f"\nAfter promotion: {junior_dev}")

# This is blocked by our setter
try:
    junior_dev.salary = -1000
except ValueError as e:
    print(f"\nCaught invalid salary update: {e}")

# repr() is what you see in a REPL or when printing a list of objects
print(f"\nrepr: {repr(junior_dev)}")
▶ Output
Maria | Engineering | £45,000.00 salary | £3,600.00 bonus
James | Engineering | £85,000.00 salary | £12,750.00 bonus

After promotion: Maria | Engineering | £52,000.00 salary | £4,160.00 bonus

Caught invalid salary update: Salary cannot be negative: -1000

repr: Employee(full_name='Maria Santos', salary=52000.0, department='Engineering')
⚠️
Watch Out: __str__ vs __repr__Always implement both __repr__ and __str__ for any class you'll use beyond a toy example. __repr__ should be unambiguous and ideally look like valid Python that could recreate the object. __str__ should be human-readable. If you only implement one, implement __repr__ — Python falls back to it when __str__ is missing, but not the other way around.
FeatureInstance MethodClass MethodStatic Method
First parameterself (instance)cls (the class)None
Access instance state?YesNoNo
Access class state?Yes (via self or ClassName)Yes (via cls)No
Decorator needed?None@classmethod@staticmethod
Primary use caseObject behaviour & mutationAlternative constructorsUtility functions related to the concept
Call on instance?YesYes (but unusual)Yes (but unusual)
Call on class?No (needs an instance)Yes — preferredYes — preferred
Real-world exampleaccount.deposit(100)datetime.fromisoformat()str.maketrans()

🎯 Key Takeaways

  • __init__ is an initialiser, not a constructor — the object already exists when it runs. The real constructor is __new__, which you almost never need to touch.
  • Use class methods as alternative constructors when your class can be built from multiple input formats — it keeps __init__ clean and gives callers a readable API (e.g. Product.from_csv_row()).
  • The @property decorator is Python's way of adding getters and setters without breaking existing code — it enforces valid state while keeping attribute-style access that feels natural to callers.
  • Always implement __repr__ for any class you'll use in production. It's what appears in logs, debuggers, and the REPL — making it useful saves you hours of print-statement debugging.

⚠ Common Mistakes to Avoid

  • Mistake 1: Mutable default arguments in __init__ — Writing def __init__(self, items=[]) causes ALL instances to share the same list object. Symptom: adding an item to one object mysteriously appears in another. Fix: always use None as the default and set the mutable inside the body — self.items = items if items is not None else [].
  • Mistake 2: Confusing class attributes with instance attributes — If you set self.interest_rate = 0.05 inside a method, you create an instance attribute that shadows the class attribute. Future changes to BankAccount.interest_rate won't affect that instance. Symptom: changing the class attribute has no effect on some objects. Fix: access class attributes via ClassName.attribute to be explicit about scope, and never rebind them via self unless instance-level override is deliberate.
  • Mistake 3: Forgetting that _ and __ prefixes don't enforce true privacy — Accessing obj._private_field works fine in Python and is sometimes necessary (e.g. in tests). The real mistake is designing your class assuming external code will respect the convention. Fix: use @property with no setter for read-only state, and raise descriptive errors in setters for invalid mutations — that's enforceable behaviour, unlike naming conventions.

Interview Questions on This Topic

  • QWhat's the difference between a class attribute and an instance attribute, and can you describe a bug that arises from confusing the two?
  • QWhen would you choose a @classmethod over a @staticmethod, and vice versa? Give a concrete example for each.
  • QWhat does Python's @property decorator actually do under the hood, and how does it let you add validation to an attribute without changing the class's public API?

Frequently Asked Questions

What is the difference between a class and an object in Python?

A class is the blueprint — it defines the structure and behaviour but holds no data itself. An object is a live instance created from that blueprint, with its own copy of the instance attributes. You can create thousands of independent objects from one class, just like stamping cookies from one cutter.

Why does Python use 'self' in class methods?

When you call a method on an instance, Python automatically passes that instance as the first argument. 'self' is just the conventional name for that parameter — it's how the method knows which object's data to read or change. Technically you can name it anything, but the convention is universal and you should always follow it.

When should I use a class instead of a plain dictionary or function in Python?

Use a class when you have both data AND behaviour that belong together and will be used repeatedly. A plain dictionary is fine for passive data bags. A function is fine for a single transformation. But if you find yourself writing functions that all take the same dictionary as their first argument, that's a strong signal to reach for a class instead.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousHigher Order Functions in PythonNext →Inheritance in Python
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged