Skip to content
Home Python Python __init__ Mutable Defaults — The Shared State Bug

Python __init__ Mutable Defaults — The Shared State Bug

Where developers are forged. · Structured learning · Free forever.
📍 Part of: OOP in Python → Topic 1 of 9
Default list in __init__ caused cross-user data leaks in production.
⚙️ Intermediate — basic Python knowledge assumed
In this tutorial, you'll learn
Default list in __init__ caused cross-user data leaks in production.
  • __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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • A class is a blueprint; an object is the live instance — each object holds its own data
  • __init__ initializes the existing object (not construct it) — __new__ allocates memory
  • Three method types: instance (self), class (@classmethod), static (@staticmethod) — one does all but pick the right one
  • @property exposes computed or validated attributes without breaking callers' code
  • Worst mistake: mutable default arguments in __init__ share state across all instances — use None defaults
🚨 START HERE

Python OOP Debugging Cheat Sheet

Five-second fixes for the most common OOP bugs that developers waste hours on.
🟡

Mutable default argument sharing

Immediate ActionStop reproducing the bug — it's deterministic once you understand it. Check the error line in logs.
Commands
python -c "import inspect; print(inspect.signature(YourClass.__init__))"
grep -rn "def __init__(self.*=\[\|={}" your_code/*.py
Fix NowReplace all mutable defaults in __init__ with `None` and create the mutable inside the body.
🟡

Property setter not called — value unchanged after assignment

Immediate ActionCheck if you're assigning to the backing attribute (e.g., `_name`) directly. Only `obj.name = ...` triggers the setter.
Commands
python -c "print(type(YourClass.name))" # should be <class 'property'>
Get attribute access trace: `python -m trace --trace your_script.py 2>&1 | grep 'property'`
Fix NowRename the backing variable with underscore and ensure setter uses `self._name` (not `self.name` to avoid infinite recursion).
🟡

Missing super().__init__() in child class

Immediate ActionCheck the child class __init__: if it overwrites the parent's init without calling super(), the parent's attributes won't be initialized.
Commands
python -c "import inspect; mro = [c.__name__ for c in YourClass.__mro__]; print(mro)"
Inspect class attributes: `python -c "from your_module import YourClass; print([a for a in dir(YourClass) if not a.startswith('_')])"`
Fix NowAdd `super().__init__(parent_args)` as the first line of the child's __init__.
Production Incident

The Shared List That Corrupted Every User's Shopping Cart

A production cart service mutated one user's items and they appeared in another's. Root cause: a mutable default argument in __init__.
SymptomUsers reported items from other users' shopping carts appearing in their own. Inconsistent state across sessions, data loss on checkout.
AssumptionThe developer assumed each new object got its own empty list because the default argument was evaluated at object creation.
Root causedef __init__(self, items=[]) — the list literal is evaluated once at class definition time, not on each __init__ call. All instances share the same list object.
FixChange default to None and create a new list inside __init__: self.items = items if items is not None else [].
Key Lesson
Mutable default arguments are evaluated once, at function definition time — not per call.Always use None as sentinel for mutable defaults and create the actual mutable inside the method body.Add a unit test that verifies instance independence: obj1.add(1); assert len(obj2.items) == 0.
Production Debug Guide

Symptom → Action quick reference for the three most common class-related failures

Two objects of the same class appear to share state (modifying one affects the other)Check __init__ for mutable default arguments (lists, dicts, sets). Replace with None and initialize inside the body. Also check that class attributes aren't being mutated via self.
AttributeError: 'ClassName' object has no attribute 'something'Verify that __init__ exists and assigns self.something. If the attribute is set in a method called after init, ensure that method is invoked before access. Use hasattr(obj, 'something') to check.
Calling a method like instance.some_static() works but acts on the class instead of the instanceCheck the decorator: @staticmethod methods receive no self or cls. If you need instance state, remove @staticmethod. If you need class state, use @classmethod.
Property setter validation isn't running on attribute assignmentEnsure you have both @property for the getter and @<property_name>.setter for the setter. The setter is not called if you assign to the underscore-named backing attribute directly (e.g., obj._salary = 50000 bypasses validation).

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 behaviour 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 Keyword
You 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.
📊 Production Insight
Forgetting that self is positional: calling BankAccount.deposit(100) instead of account.deposit(100) produces the infamous 'missing 1 required positional argument: self'.
Always call methods through the instance, not the class, unless you explicitly pass the instance.
Rule: if you see that error, you're calling the method on the class and missing the instance argument.
🎯 Key Takeaway
__init__ is an initialiser, not a constructor. The object already exists when it runs.
self is just the first argument — the instance itself.
The convention is universal: break it and your team will hate you.

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 Constructors
When 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.
📊 Production Insight
A common production bug: someone writes a static method for a behaviour that actually needs instance state. The method works in tests but fails at runtime because self is missing.
Worse: using a class method when a static method is needed accidentally accesses class state that may change unexpectedly (e.g., reading a config class attribute that gets overwritten).
Rule: start with instance method — the most flexible. Only promote to class or static when you have a concrete reason.
🎯 Key Takeaway
Instance methods: 90% of your methods — use for object-specific behaviour.
Class methods: alternative constructors and class-level factory methods.
Static methods: pure utility that belongs in the class namespace.
If a method doesn't use self or cls, question whether it belongs on the class at all.

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.
📊 Production Insight
Properties look like plain attributes — that's the point. But if you later add validation via a setter, any code that was assigning directly to the backing attribute (e.g., obj._salary = x) will bypass the setter silently.
In production, this leads to data invariants being violated. The fix: never access _salary from outside the class, even in tests. If you must, use the property.
Rule: property setters give you controlled mutation; always go through them.
🎯 Key Takeaway
@property gives you getter/setter control without breaking existing callers.
Use underscore prefixes to signal 'internal' — but remember they're not enforced.
Always implement __repr__ for every class — it's your first debugging tool.

Inheritance and Method Resolution Order — Supercharge Without Breaking

Inheritance lets a child class reuse and extend a parent's behaviour. Python supports single and multiple inheritance, and its method resolution order (MRO) determines which method is called when there's ambiguity. The MRO uses the C3 linearization algorithm — it's deterministic, but can produce surprising results if you don't understand it.

The golden rule: always call super().__init__() in the child's __init__. If you skip it, the parent's constructor never runs, and instance attributes defined there won't exist. This is the most common inheritance bug in production.

Multiple inheritance works via cooperative multiple dispatch: each class in the MRO gets a chance to run its __init__ via the super() chain. The MRO respects the order of base classes and ensures each class is visited exactly once. Use the __mro__ attribute to inspect the order.

employees_inheritance.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
class Employee:
    def __init__(self, name: str, salary: float):
        self.name = name
        self.salary = salary
        print(f"Employee.__init__ called for {self.name}")

    def work(self) -> str:
        return f"{self.name} is working."


class Manager(Employee):
    def __init__(self, name: str, salary: float, team_size: int):
        super().__init__(name, salary)
        self.team_size = team_size
        print(f"Manager.__init__ called for {self.name}")

    def work(self) -> str:
        return f"{self.name} is managing {self.team_size} people."


class Developer(Employee):
    def __init__(self, name: str, salary: float, tech_stack: list):
        super().__init__(name, salary)
        self.tech_stack = tech_stack
        print(f"Developer.__init__ called for {self.name}")

    def work(self) -> str:
        return f"{self.name} is coding with {', '.join(self.tech_stack)}."


class TechLead(Manager, Developer):
    def __init__(self, name: str, salary: float, team_size: int, tech_stack: list):
        # super() follows the MRO: TechLead -> Manager -> Developer -> Employee -> object
        super().__init__(name, salary, team_size, tech_stack)
        print(f"TechLead.__init__ called for {self.name}")

    def work(self) -> str:
        return f"{self.name} is leading the team and coding."


# Check MRO
print("MRO:", [c.__name__ for c in TechLead.__mro__])
print()

tl = TechLead("Alice", 120_000, 5, ["Python", "Kubernetes"])
print(tl.work())
print()

# Demonstrate that single inheritance still works
mgr = Manager("Bob", 90_000, 3)
print(mgr.work())
▶ Output
MRO: ['TechLead', 'Manager', 'Developer', 'Employee', 'object']

Employee.__init__ called for Alice
Developer.__init__ called for Alice
Manager.__init__ called for Alice
TechLead.__init__ called for Alice
Alice is leading the team and coding.

Employee.__init__ called for Bob
Manager.__init__ called for Bob
Bob is managing 3 people.
🔥The Super() Chain in Multiple Inheritance
In multiple inheritance, super().__init__() doesn't just call the parent's __init__ — it calls the next class in the MRO. That's why the order matters. In the TechLead example, super() in Developer's __init__ calls Employee's __init__, not Manager's. The MRO ensures each class in the chain is called exactly once.
📊 Production Insight
Skipping super().__init__() in a subclass is the top cause of missing attribute errors in production. The parent's attributes are never initialized, so self.name raises AttributeError.
Even worse: when you have a diamond hierarchy (multiple inheritance), forgetting super() in any class breaks the entire chain, leaving some parent attributes uninitialized.
Rule: always call super().__init__() in every __init__ — even if you think the parent doesn't need it. Consistency prevents bugs.
🎯 Key Takeaway
Always call super().__init__() in child class __init__ methods.
Multiple inheritance MRO is deterministic — inspect with Class.__mro__.
The super() call follows the MRO, not just the 'first' parent.

Magic Methods — Customize Object Behaviour for Production Code

Magic methods (dunder methods) let you define how your objects behave with Python's built-in operations: print(), ==, len(), str(), iteration, and more. They're the difference between a class that feels like a Python native and one that feels clunky.

Core magic methods every class should implement
  • __repr__: unambiguous developer-facing representation
  • __str__: user-facing string (falls back to __repr__ if missing)
  • __eq__ and __hash__: equality and hashability (must be paired for use in sets/dicts)
  • __len__: support for len(obj)
  • __getitem__: subscription (obj[key])
  • __call__: make an object callable (obj())

Critical pairing: if you define __eq__, you should either define __hash__ or set it to None. Mutable objects should set __hash__ = None to prevent them from being used in sets or dict keys — mutating an object that's in a set breaks the data structure.

vector_magic.py · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
class Vector:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"

    def __str__(self) -> str:
        return f"({self.x}, {self.y})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Vector):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    def __hash__(self) -> int:
        # Since Vector is mutable in theory, but we treat as immutable, we provide hash
        return hash((self.x, self.y))

    def __add__(self, other: 'Vector') -> 'Vector':
        return Vector(self.x + other.x, self.y + other.y)

    def __len__(self) -> int:
        # Manhatten length as a silly example
        return int(abs(self.x) + abs(self.y))

    def __getitem__(self, index: int) -> float:
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        raise IndexError("Vector index out of range")

    def __call__(self) -> float:
        # Return magnitude
        return (self.x ** 2 + self.y ** 2) ** 0.5

# Using magic methods
v1 = Vector(3, 4)
v2 = Vector(3, 4)
v3 = Vector(1, 2)

print(repr(v1))                # __repr__
print(str(v1))                 # __str__
print(v1 == v2)               # __eq__
print(v1 == v3)               # __eq__
print(hash(v1))               # __hash__ (used in sets/dicts)

s = {v1, v2}                  # __hash__ + __eq__
print(f"Set size: {len(s)}")  # Because v1 == v2, only one element

print(v1 + v3)                # __add__
print(len(v1))                # __len__
print(v1[0], v1[1])          # __getitem__
print(v1())                   # __call__ as magnitude
▶ Output
Vector(3, 4)
(3, 4)
True
False
1076899327
Set size: 1
Vector(4, 6)
7
3 4
5.0
⚠ __eq__ and __hash__ Must Agree
If you define __eq__ but not __hash__, Python sets __hash__ = None (making the object unhashable). If you define __hash__ but not __eq__, Python's default __eq__ (identity check) will break your hash-based collections. Always define both, or explicitly set __hash__ = None for mutable classes to prevent misuse.
📊 Production Insight
A common production bug: a mutable class defines __eq__ but not __hash__. Instances become unhashable — you can't use them in sets or as dict keys. If you try, you get a TypeError.
Worse: you define __eq__ and __hash__ on a class that's actually mutable, then mutate an instance while it's in a set. The set gets corrupted — you can't find the object anymore.
Rule: make immutable classes hashable (tuple of fields). For mutable classes, set __hash__ = None to avoid the hazard entirely.
🎯 Key Takeaway
Magic methods integrate your class with Python's operators and built-ins.
Always implement __repr__; implement __str__ for user output.
If you define __eq__, define __hash__ too — or set __hash__ = None for mutable classes.
🗂 Method Type Comparison
Instance Method vs Class Method vs Static Method
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.
  • Inheritance requires consistent super().__init__() calls in every subclass. Skipping it breaks the chain and leaves parent attributes uninitialised.
  • If you define __eq__, also define __hash__ — or explicitly set __hash__ = None for mutable classes to prevent corrupted sets/dicts.

⚠ Common Mistakes to Avoid

    Mutable default arguments in __init__
    Symptom

    Modifying an attribute that defaults to a mutable (list, dict, set) in one object affects all other objects that share the same default. Data corruption across instances.

    Fix

    Replace mutables with None as default and create a fresh mutable inside __init__: self.items = items if items is not None else [].

    Confusing class attributes with instance attributes
    Symptom

    Setting self.interest_rate = 0.05 inside a method shadows the class attribute. Future changes to BankAccount.interest_rate no longer affect that instance, leading to inconsistent behaviour.

    Fix

    Access class attributes via ClassName.attribute explicitly. Never rebind a class attribute via self unless you intend to create an instance-level override.

    Forgetting that `_` and `__` prefixes don't enforce true privacy
    Symptom

    External code modifies obj._private_field directly, bypassing validation. Or double-underscore mangling blocks access from subclasses unexpectedly.

    Fix

    Use @property with no setter for read-only state. Raise descriptive errors in setters for invalid mutations. Document that underscore fields are internal and don't rely on them being inaccessible.

    Skipping `super().__init__()` in subclasses
    Symptom

    Child class instances raise AttributeError for attributes defined in the parent's __init__. Only the child's own attributes are set.

    Fix

    Always call super().__init__(args) as the first line in the child's __init__. In multiple inheritance, ensure all cooperating classes use super() consistently.

    Defining __eq__ without __hash__ (or vice versa)
    Symptom

    Objects cannot be used in sets or as dict keys (TypeError: unhashable type). Or sets break because equal objects have different hashes.

    Fix

    If the class is immutable, define both __eq__ and __hash__ based on the same tuple of fields. If mutable, define __eq__ and set __hash__ = None explicitly.

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?JuniorReveal
    A class attribute is defined directly on the class body and is shared across all instances. An instance attribute is assigned via self.attribute inside methods and is unique to each object. The classic bug: if you mutate a class attribute through self, you create a new instance attribute that shadows the class attribute. Example: self.interest_rate = 0.05 after the class defined interest_rate = 0.03. Now that instance no longer uses the class default. Worse: if you mutate a mutable class attribute (e.g., self.items.append(x) where items is a class attribute), you modify the shared list for all instances. The fix: always access class attributes via ClassName.attribute to be explicit, and never mutate them from instance methods.
  • QWhen would you choose a @classmethod over a @staticmethod, and vice versa? Give a concrete example for each.Mid-levelReveal
    Use @classmethod when the method needs access to the class itself — typically for alternative constructors or factory methods that need to instantiate the class (e.g., Product.from_csv_row()). The cls parameter allows it to work correctly with subclasses. Use @staticmethod when the method is a pure utility that doesn't need class or instance data but logically belongs under the class namespace (e.g., Product.is_valid_price()). If you ever access class state inside a @staticmethod, it should be a @classmethod instead. Real example: datetime.fromisoformat is a @classmethod because it creates a new datetime instance; str.maketrans is a @staticmethod because it just builds a translation table with no reference to a str instance or class.
  • 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?SeniorReveal
    Under the hood, @property creates a descriptor object that intercepts attribute access. When you decorate a getter method with @property, Python calls that method whenever you access the attribute name. Adding @attr.setter registers a separate setter descriptor that is called on assignment. The magic is that external code still writes obj.attr = value — nothing changes in the caller's syntax. You can start with a plain attribute (no property), and later add property getters/setters without touching any code that reads or writes the attribute. This is a practical application of the Open/Closed principle: the class internals change, but the API remains stable. The validation is enforced by the setter raising exceptions (ValueError, TypeError) when constraints are violated.
  • QExplain Python's method resolution order (MRO) and how it resolves the diamond problem in multiple inheritance.SeniorReveal
    Python's MRO uses the C3 linearization algorithm. It produces a deterministic order of class lookup that ensures each class appears exactly once and that all parents are visited before their children. For example, class C(A, B) — if A and B both define a method, MRO decides which gets called first. The diamond problem (where both parent classes inherit from a common grandparent) is resolved by ensuring the grandparent's class only appears once in the MRO, even if it's inherited through two paths. You can inspect the MRO using ClassName.__mro__ or ClassName.mro(). When you call super() in a class with multiple inheritance, it follows the MRO, not just the first parent. This means super().__init__() in a diamond hierarchy will call each class's __init__ exactly once in the correct order, provided all classes use super() consistently.

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.

What is the purpose of __repr__ vs __str__?

__repr__ should be an unambiguous representation of the object, ideally valid Python code that could recreate it (e.g., Vector(3, 4)). It's used by the REPL, logging, and debugging tools. __str__ should be a human-readable representation (e.g., (3, 4)). It's used by print() and str(). Python falls back to __repr__ if __str__ is missing, but not the other way around. Always implement at least __repr__ for every class you use in production.

How does Python's name mangling work with double underscores?

When you write self.__attribute, Python automatically rewrites it to self._ClassName__attribute. This prevents accidental overrides in subclasses. For example, a parent class with self.__private and a child that also defines self.__private will have distinct attributes: _Parent__private and _Child__private. It's not true privacy (you can still access the mangled name), but it avoids name collisions. Use double underscores only when you specifically need to prevent subclass interference; use single underscore for most internal attributes.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

Next →Inheritance in Python
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged