Python __init__ Mutable Defaults — The Shared State Bug
Default list in __init__ caused cross-user data leaks in production.
20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.
- 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
This article tackles Python's object model from the perspective of a working developer who's been burned by its quirks. The centerpiece is the infamous mutable default argument bug in __init__ — where def __init__(self, items=[]) creates a single list object shared across all instances, silently corrupting state.
You'll learn why Python's __init__ is an initializer, not a constructor (that's __new__), and how this distinction leads to the shared state trap. The article then builds outward: instance methods operate on self, class methods on cls (useful for factory patterns like Django's Model.objects.create()), and static methods are just namespaced functions.
You'll see how @property replaces getter/setter boilerplate (used pervasively in SQLAlchemy and Pydantic) while maintaining backward compatibility. The MRO discussion covers C3 linearization — why in diamond inheritance (e.g., Django's multiple inheritance mixins) resolves predictably, and when to avoid deep hierarchies.super()
Magic methods like __enter__/__exit__ (context managers for database transactions) and __getattr__ (lazy loading in ORMs) are explained with production patterns. Finally, property descriptors are demystified: the __get__, __set__, __delete__ protocol that powers @property, classmethod, and even Django's ForeignKey descriptor.
If you've ever wondered why [{}] * 5 creates five references to the same dict, or why isinstance checks feel fragile, this article gives you the mental model to write predictable, debuggable classes.
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 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.
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}")
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.BankAccount.deposit(100) instead of account.deposit(100) produces the infamous 'missing 1 required positional argument: self'.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.
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)}")
Model.objects.get(), Model.objects.create() are all class-level entry points. It keeps __init__ clean and gives callers a readable, intention-revealing API.self is missing.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 , but you still control what happens when they do.employee.get_salary()
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.
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)}")
obj._salary = x) will bypass the setter silently._salary from outside the class, even in tests. If you must, use the property.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 in the child's super().__init__()__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 chain. The MRO respects the order of base classes and ensures each class is visited exactly once. Use the super()__mro__ attribute to inspect the order.
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())
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.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.super() in any class breaks the entire chain, leaving some parent attributes uninitialized.super().__init__() in every __init__ — even if you think the parent doesn't need it. Consistency prevents bugs.super().__init__() in child class __init__ methods.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(), iteration, and more. They're the difference between a class that feels like a Python native and one that feels clunky.str()
__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 forlen(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.
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
__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.__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.__hash__ = None to avoid the hazard entirely.Property Descriptors — The Hidden Machinery Behind @property
You've used @property. You probably think it's magic. It's not. It's a descriptor protocol — __get__, __set__, __delete__ — running under the hood. Every time you write @property, Python creates a descriptor object that intercepts attribute access.
Why should you care? Because when you understand descriptors, you stop writing boilerplate getters/setters and start building reusable access-control logic. Need a field that logs every read? Auto-validates on write? Converts units on assignment? Write a descriptor once, reuse across models.
The pattern: define a class with __set_name__, __get__, and __set__. Attach it as a class attribute. Python calls your descriptor methods automatically. No metaclass madness needed. This is how Django fields, SQLAlchemy columns, and Pydantic validators work at their core.
Descriptors separate the "how" of attribute access from the "what" of your domain logic. That's the difference between cargo-culting @property and actually controlling object behavior.
// io.thecodeforge — python tutorial class ValidatedAttribute: def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name) def __set__(self, obj, value): if not (-273.15 <= value <= 5000): raise ValueError(f"{self.name} out of range") obj.__dict__[self.name] = value class HeatExchanger: temp_celsius = ValidatedAttribute() pressure_kpa = ValidatedAttribute() def __init__(self, temp, pressure): self.temp_celsius = temp self.pressure_kpa = pressure e = HeatExchanger(100, 200) print(e.temp_celsius) e.temp_celsius = -300 # Boom
Slots — Shrink Memory, Win at Multiprocessing, Kill Attribute Chaos
Every Python object carries a __dict__ — a hash table mapping attribute names to values. That's flexible. It's also a memory hog (up to 10x overhead) and a permission slip for typos: self.paylaod = True won't raise an error until runtime.
Enter __slots__. Define a fixed tuple of attribute names. Python allocates space for exactly those attributes — no __dict__, no accidental new attributes, 30-50% memory savings on objects with 5+ attributes. In benchmarks, slot-based objects can reduce memory from 56 bytes to 40 bytes per instance. Scale that to 10 million objects in a data pipeline and you just saved 160 MB.
But here's the kicker: slots make multiprocessing faster. Pickling a slot-based object serializes a compact struct, not a sprawling dict. The serialization payload is smaller, the I/O is faster, and you avoid the pickle overhead of arbitrary dict keys.
Warning: slots break sublassing if you don't repeat them. And you lose __dict__, so dynamic attribute tricks die. Decide: do you need flexibility, or do you need performance? Most production code should default to slots on data-heavy classes.
// io.thecodeforge — python tutorial import sys class PointDict: def __init__(self, x, y, z): self.x = x self.y = y self.z = z class PointSlots: __slots__ = ('x', 'y', 'z') def __init__(self, x, y, z): self.x = x self.y = y self.z = z d = PointDict(1, 2, 3) s = PointSlots(1, 2, 3) print(f"Dict size: {sys.getsizeof(d.__dict__)}") print(f"Slots size: {sys.getsizeof(s)}")
Iterators — Why For Loops Work and When to Write Your Own
Every for loop in Python relies on iterators. When you write for x in obj, Python calls iter(obj) to get an iterator object, then repeatedly calls on it until next()StopIteration is raised. This protocol — __iter__ returning an iterator with __next__ — is what powers loops, list comprehensions, and unpacking. Most built-in types return iterators that traverse data eagerly. You write custom iterators when you need lazy evaluation, infinite sequences, or stateful traversal that a generator can't cleanly express. The cost is manual state management inside __next__. The gain: full control over iteration termination and side effects. Production code that processes streams, paginates APIs, or walks trees benefits from explicit iterators over hidden loops. The iterator protocol is the backbone of Python's iteration model — master it and you own the loop.
// io.thecodeforge — python tutorial class Counter: def __init__(self, limit): self.limit = limit self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.limit: raise StopIteration value = self.current self.current += 1 return value for i in Counter(3): print(i) # 0 1 2
self from __iter__ are both iterable and iterator — but they consume the sequence on first pass. Use separate iterator objects to allow multiple traversals.__iter__ returning an iterator; an iterator has __next__ raising StopIteration when done.Generators — Lazy Sequences That Don't Blow Your Memory
A generator is a function with yield instead of return. Each call returns a generator iterator that suspends execution at yield, remembers its state, and resumes on the next call. This laziness is critical for processing large datasets, infinite sequences, or streaming data without loading everything into RAM. Behind the scenes, Python compiles the generator function into an object with next()__iter__ and __next__ — same protocol as custom iterators but with automatic state management. The method lets you terminate a generator early, useful for cleanup in long-running processes. Generator expressions (close()(x for x in range(10))) are syntactic sugar for simple generators. The trade-off: generators are single-pass and don't support indexing or random access. Use them when memory pressure exceeds the need for random access — common in log processing, API pagination, and reading large files line by line.
// io.thecodeforge — python tutorial def read_large(filepath): with open(filepath) as f: for line in f: yield line.strip() # consumes only one line at a time for entry in read_large("data.csv"): if "ERROR" in entry: print(entry) break
list(gen) exhausts it — subsequent iterations yield nothing. Reassign the generator or use itertools.tee for multiple consumers.yield, pausing execution between yields — ideal for unbounded or memory-intensive sequences.The Shared List That Corrupted Every User's Shopping Cart
def __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.None and create a new list inside __init__: self.items = items if items is not None else [].- Mutable default arguments are evaluated once, at function definition time — not per call.
- Always use
Noneas 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.
None and initialize inside the body. Also check that class attributes aren't being mutated via self.__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.instance.some_static() works but acts on the class instead of the instance@staticmethod methods receive no self or cls. If you need instance state, remove @staticmethod. If you need class state, use @classmethod.@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).python -c "import inspect; print(inspect.signature(YourClass.__init__))"grep -rn "def __init__(self.*=\[\|={}" your_code/*.pyNone and create the mutable inside the body.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'`self._name (not self.name to avoid infinite recursion).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('_')])"`super().__init__(parent_args) as the first line of the child's __init__.| Feature | Instance Method | Class Method | Static Method |
|---|---|---|---|
| First parameter | self (instance) | cls (the class) | None |
| Access instance state? | Yes | No | No |
| Access class state? | Yes (via self or ClassName) | Yes (via cls) | No |
| Decorator needed? | None | @classmethod | @staticmethod |
| Primary use case | Object behaviour & mutation | Alternative constructors | Utility functions related to the concept |
| Call on instance? | Yes | Yes (but unusual) | Yes (but unusual) |
| Call on class? | No (needs an instance) | Yes — preferred | Yes — preferred |
| Real-world example | account.deposit(100) | datetime.fromisoformat() | str.maketrans() |
Key takeaways
Product.from_csv_row()).super().__init__() calls in every subclass. Skipping it breaks the chain and leaves parent attributes uninitialised.Common mistakes to avoid
5 patternsMutable default arguments in __init__
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
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.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
obj._private_field directly, bypassing validation. Or double-underscore mangling blocks access from subclasses unexpectedly.@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
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)
Interview Questions on This Topic
What's the difference between a class attribute and an instance attribute, and can you describe a bug that arises from confusing the two?
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.When would you choose a @classmethod over a @staticmethod, and vice versa? Give a concrete example for each.
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.What 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?
@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.Explain Python's method resolution order (MRO) and how it resolves the diamond problem in multiple inheritance.
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
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.
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.
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.
__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 and print(). 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.str()
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.
20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.
That's OOP in Python. Mark it forged?
7 min read · try the examples if you haven't