Constructors, Instance vs Class Attributes
Constructor validation and defaults, the critical difference between instance and class attributes, the mutable-default-class-attribute trap, attribute lookup order, and __dict__.
__init__ With Default Values and Real Validation
Module 19 introduced __init__ as the method that sets up an object's starting state. In real code, a constructor rarely just assigns parameters straight through — it usually needs to supply sensible defaults for optional data, and reject clearly invalid input before the object is ever allowed to exist in a broken state.
class Employee:
def __init__(self, name, salary, department="Unassigned"):
self.name = name
self.salary = salary
self.department = department
alice = Employee("Alice", 95000)
bob = Employee("Bob", 88000, "Engineering")
print(alice.department) # "Unassigned"
print(bob.department) # "Engineering"Default parameter values in __init__ follow the exact same rules covered in the Functions module — they are evaluated once, and mutable defaults are dangerous for reasons that will matter enormously in Part 03 below, once the discussion shifts from parameter defaults to class attribute defaults.
Validating input inside the constructor
Because __init__ is the one guaranteed gatekeeper every instance passes through on the way into existence, it is the natural place to reject data that would leave the object in an invalid state. Raising an exception from inside __init__ is completely normal — it simply means the object is never created at all.
class Employee:
def __init__(self, name, salary, department="Unassigned"):
if salary < 0:
raise ValueError(f"Salary cannot be negative: {salary}")
if not name.strip():
raise ValueError("Employee name cannot be empty")
self.name = name
self.salary = salary
self.department = department
Employee("", 50000)
# ValueError: Employee name cannot be empty
# Note: no Employee object was ever created — the exception happens before self.name is even set__init__ means invalid objects simply cannot exist in your program — every piece of code that later receives an Employee instance can trust that its salary is non-negative and its name is non-empty, without re-checking. This is a small, concrete example of an "invariant," the same concept introduced in Module 19's discussion of when a class earns its complexity.The Critical Distinction: Where Does the Data Actually Live?
Every attribute you have seen so far — set with self.attribute = value — is an instance attribute: it lives on the individual object, and every instance gets its own separate copy. Python also supports class attributes: values assigned directly inside the class body, not inside a method, that are shared by every instance of the class until an individual instance overrides them.
class Employee:
company_name = "Northwind Traders" # CLASS attribute — one copy, shared by all instances
def __init__(self, name, salary):
self.name = name # INSTANCE attribute — separate copy per instance
self.salary = salary # INSTANCE attribute — separate copy per instance
alice = Employee("Alice", 95000)
bob = Employee("Bob", 88000)
print(alice.company_name) # "Northwind Traders"
print(bob.company_name) # "Northwind Traders" — the SAME string object, sharedClass attributes are genuinely useful for data that really is the same across every instance — a company name shared by every Employee, a species name shared by every dog in Module 19's example, or a constant like a tax rate. The moment you assign to that attribute through a specific instance, though, something subtle happens that trips up almost every engineer the first time they encounter it.
alice.company_name = "Acme Corp" # this does NOT change the shared class attribute
print(alice.company_name) # "Acme Corp" — alice now has her own instance attribute
print(bob.company_name) # "Northwind Traders" — completely unaffected
print(Employee.company_name) # "Northwind Traders" — the class attribute itself never changedalice.company_name = "Acme Corp" does not reach into the class and modify the shared value — it creates a brand new instance attribute on alice specifically, which simply shadows the class attribute of the same name whenever you access it through alice. The class attribute itself, and every other instance that has not been individually overridden, is completely untouched.
The Single Most Famous Gotcha in Python OOP
Everything in Part 02 was relatively harmless when the shared class attribute was an immutable value like a string. It becomes a genuine, production-breaking bug the moment the class attribute is a mutable object — most commonly a list or a dict — because mutating it does not create a new instance attribute the way reassigning it does. Mutating reaches into the one shared object directly, and every instance sees the change.
class ShoppingCart:
items = [] # LOOKS like a fresh empty list per cart. It is NOT.
def __init__(self, owner):
self.owner = owner
def add_item(self, item):
self.items.append(item) # .append() MUTATES the shared list — it doesn't reassign it
alice_cart = ShoppingCart("Alice")
bob_cart = ShoppingCart("Bob")
alice_cart.add_item("Laptop")
print(alice_cart.items) # ['Laptop']
print(bob_cart.items) # ['Laptop'] — Bob's cart has Alice's laptop in it! Same shared list.This is the exact same underlying trap as the mutable default argument gotcha from the Functions module, wearing a different costume. items = [] at the class level runs exactly once, when the class itself is defined — not once per instance. Every ShoppingCart object that does not explicitly get its own self.items is reading and mutating that single, shared list. Because self.items.append(...) mutates the object in place rather than reassigning self.items to something new, Part 02's "assigning through an instance creates a new instance attribute" escape hatch never kicks in — there is no reassignment happening at all, just mutation of the one object every instance is still pointing at.
The fix — always create mutable attributes inside __init__
class ShoppingCart:
def __init__(self, owner):
self.owner = owner
self.items = [] # created fresh, INSIDE __init__ — a genuinely new list for every instance
def add_item(self, item):
self.items.append(item)
alice_cart = ShoppingCart("Alice")
bob_cart = ShoppingCart("Bob")
alice_cart.add_item("Laptop")
print(alice_cart.items) # ['Laptop']
print(bob_cart.items) # [] — correctly empty, independent of alice's cart__init__, assigned to self, every time. Class attributes should be reserved for values that are genuinely, deliberately meant to be shared and identical across every instance — or for immutable defaults, which are safe precisely because mutating them is impossible; any change necessarily creates a new object and a new instance attribute instead.How Python Actually Finds x.attribute
Understanding why the mutable default trap happens — and why Part 02's "shadowing" behavior works the way it does — requires knowing the actual lookup rule Python follows when you write instance.attribute. Python checks the instance's own attributes first. Only if the instance does not have that attribute does it fall back to checking the class (and, once inheritance is introduced in Module 21, the class's parent classes, in a defined order).
class Employee:
company_name = "Northwind Traders"
def __init__(self, name):
self.name = name
alice = Employee("Alice")
print(alice.company_name)
# 1. Python checks: does the "alice" INSTANCE have an attribute called company_name? No.
# 2. Python falls back to the EMPLOYEE CLASS: does it have company_name? Yes -> "Northwind Traders"
alice.company_name = "Acme Corp"
print(alice.company_name)
# 1. Python checks: does the "alice" INSTANCE have company_name now? YES (just created it)
# 2. Found on the instance -> stops looking -> "Acme Corp". The class is never even consulted.This is exactly why mutating a shared mutable class attribute (Part 03) is dangerous, but reassigning a class attribute through an instance (Part 02) is safe: .append() changes the one object the class attribute points to, which every instance still falls back to finding via the class. A direct = assignment through an instance, on the other hand, creates a brand new instance attribute that the lookup order finds first, before it ever needs to fall back to the class at all.
__dict__ — Seeing an Object's Instance Attributes Directly
Every ordinary Python object stores its instance attributes internally in a dictionary, accessible directly as __dict__. This is genuinely useful for debugging — a quick way to see exactly what state an object is actually holding, without needing to know every attribute name in advance.
class Employee:
company_name = "Northwind Traders"
def __init__(self, name, salary):
self.name = name
self.salary = salary
alice = Employee("Alice", 95000)
print(alice.__dict__)
# {'name': 'Alice', 'salary': 95000}
# Notice company_name is NOT here — it lives on the class, not the instance.
print(Employee.__dict__.keys())
# includes 'company_name', '__init__', and other class-level machineryThis makes the instance-vs-class distinction completely concrete: alice.__dict__ only ever contains what was actually set on alice specifically — proof that company_name genuinely lives elsewhere, on the class, until an instance assignment creates its own copy.
Deleting attributes with del
Just as you can add an instance attribute at any time, you can remove one with del. This removes the attribute from the instance's own __dict__ — if a class attribute of the same name exists, attribute lookup falls straight back to it, exactly as described in Part 04.
alice.company_name = "Acme Corp" # creates an instance attribute, shadowing the class one
print(alice.company_name) # "Acme Corp"
del alice.company_name # removes ONLY the instance attribute
print(alice.company_name) # "Northwind Traders" — falls back to the class attribute
del alice.salary
print(alice.salary)
# AttributeError: 'Employee' object has no attribute 'salary'
# salary has no class-level fallback, so deleting the instance attribute leaves nothing to findLegitimate Uses — Constants, Counters, and Configuration
None of this means class attributes are a mistake to avoid — they are the right tool for several genuinely common, safe patterns, as long as you keep Part 03's rule in mind: fine for immutable values, fine for data deliberately meant to be shared, dangerous for per-instance mutable state.
class TaxCalculator:
TAX_RATE = 0.0825 # a genuine constant, shared and immutable — perfectly safe
def __init__(self, subtotal):
self.subtotal = subtotal
def total(self):
return self.subtotal * (1 + TaxCalculator.TAX_RATE)
class User:
_next_id = 1000 # a shared counter, deliberately meant to be tracked across ALL instances
def __init__(self, name):
self.name = name
self.id = User._next_id
User._next_id += 1 # this REBINDS the class attribute itself, not through an instance
alice = User("Alice")
bob = User("Bob")
print(alice.id, bob.id) # 1000 1001 — a working shared auto-incrementing IDNotice the counter example rebinds User._next_id explicitly through the class name, not through self — writing self._next_id += 1 instead would fall into exactly the Part 02 trap: it would read the shared value once (via fallback lookup) but then create a new, separate instance attribute on that one object instead of actually incrementing the shared counter for everyone. Updating a genuinely shared class attribute correctly means updating it through the class name, not through an instance.
An Austin Ed-Tech Startup's Quiz Bug — Every Student Shared One Answer List
An ed-tech startup building an online quiz platform shipped a QuizAttempt class representing one student's attempt at one quiz. A few days after launch, support tickets started arriving: students reported seeing other students' answers already filled in when they opened a brand new quiz attempt.
class QuizAttempt:
answers = [] # intended: "every attempt starts with an empty answers list"
def __init__(self, student_id, quiz_id):
self.student_id = student_id
self.quiz_id = quiz_id
def submit_answer(self, question_id, answer):
self.answers.append({"question_id": question_id, "answer": answer})What the on-call engineer found
Exactly the Part 03 trap. answers = [] at the class level created one list, once, when the class was first loaded — every single QuizAttempt instance, for every student, for every quiz, was reading and appending to that same shared list. A student halfway through a quiz was seeing every other student's in-progress answers, appended in whatever order the calls happened to arrive across the whole platform.
The fix, and why it survived code review the first time
The original pull request had actually passed review — the reviewer skimmed answers = [] and read it as "each attempt gets an empty list," which is exactly the intuitive but wrong reading Part 03 warns about. The fix was a one-line move: relocating self.answers = [] inside __init__, exactly as shown in Part 03's corrected ShoppingCart. The team also added a lightweight lint rule flagging any mutable literal ([], , set()) assigned directly in a class body outside a method, specifically to catch this pattern automatically before it could reach production again.
Four Misconceptions About Attributes
5 Interview Questions — With Complete Answers
Attribute Mistakes Beginners Make Constantly
Errors You Will Hit With Constructors and Attributes — And Exactly Why
🎯 Key Takeaways
- ✓Validate input inside __init__ and raise on invalid data — it is the one guaranteed gatekeeper every instance passes through, so invalid objects simply cannot come into existence.
- ✓Instance attributes (self.attribute) live on each object separately. Class attributes (assigned in the class body) are shared by every instance until an instance is given its own attribute of the same name.
- ✓Assigning through an instance (alice.x = value) creates a new instance attribute — it never modifies the shared class attribute.
- ✓Mutating a shared mutable class attribute (like .append() on a class-level list) DOES affect every instance, because no reassignment happens — this is the single most famous OOP gotcha in Python.
- ✓Always create mutable instance state inside __init__ with self.attribute = [] (or {} / set()), never as a mutable class attribute, unless the sharing is genuinely intentional.
- ✓Attribute lookup checks the instance first, then falls back to the class — this is exactly why shadowing and the mutable default trap behave the way they do.
- ✓obj.__dict__ shows only what is actually stored on that instance; class attributes live separately in the class's own __dict__.
- ✓del obj.attribute removes an instance attribute; if a class attribute of the same name exists, lookup falls back to it automatically afterward.
- ✓To genuinely update a shared class attribute (like an auto-incrementing counter), reference it through the class name explicitly, not through self.
What comes next
Module 21 builds one class on top of another — inheritance, method overriding, polymorphism, and the real, practical difference between "is-a" and "has-a" relationships in your code.
Module 21 → Inheritance and PolymorphismDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.