PYTHON / OBJECT-ORIENTED PYTHON
__init__ and instance attributes
Write __init__ methods that give every new instance its own attributes with safe defaults, and inspect what actually lands in the instance __dict__.
What you will learn
- Define __init__(self, ...) and assign parameters onto self to create instance attributes
- Read an object's own attributes with instance.__dict__ or vars(instance)
- Use =None plus an in-body default instead of a mutable default like [] or {}
- Set every attribute a method will touch in __init__, even if the value is None
Understanding __init__ and instance attributes
When you write Recipe("lentil soup"), Python does two separate things: it allocates a new empty object, then calls Recipe.__init__ on it with that object as the first argument. So __init__ never builds the instance; it configures one that already exists. That is also why __init__ must return None — the value of the Recipe(...) expression is the new object itself, and Python raises TypeError if __init__ tries to hand back something else.
Instance attributes are not declared anywhere. They come into existence the moment an assignment like self.servings = 4 runs, which stores the key 'servings' in that one object's __dict__. Nothing in the class body reserves space for them, and two instances of the same class can end up with completely different sets of keys. __init__ matters because it is the one place guaranteed to run before any other method, so putting every assignment there is how you guarantee a predictable shape for the object.
Inside __init__, a parameter name and an attribute name are different things: name is a local variable that vanishes when __init__ returns, while self.name is a slot on the object that survives. That distinction also explains the classic default-argument trap. Default values are evaluated once, when the def statement executes, so tags=[] creates a single list shared by every instance that omits the argument; writing tags=None and building a fresh list in the body gives each object its own.
class Recipe:
def __init__(self, name, servings=2, tags=None):
self.name = name
self.servings = servings
self.tags = tags if tags is not None else []
def describe(self):
return f"{self.name} for {self.servings} (tags: {self.tags})"
soup = Recipe("lentil soup", servings=4)
bread = Recipe("flatbread")
soup.tags.append("vegan")
print(soup.describe())
print(bread.describe())
print(soup.__dict__)
print(Recipe.__init__(bread, "focaccia", 8))
print(bread.__dict__)__init__ runs on an already-created object, and its assignments to self are what bring that object's instance attributes into existence.
Worked examples
Why a mutable default leaks between instances
Shows that items=[] in the signature is one list shared by every instance created without an argument.
class Cart:
def __init__(self, items=[]):
self.items = items
a = Cart()
b = Cart()
a.items.append("apple")
print(a.items)
print(b.items)
print(a.items is b.items)
print(Cart.__init__.__defaults__)Example explained
Line 1items=[] is evaluated once when the class body runs, so exactly one list object exists.
Line 2self.items = items binds both instances' attribute to that same list, not to a copy.
Line 3a.items is b.items being True proves the two objects share state they should not share.
Line 4Cart.__init__.__defaults__ exposes the stored default, and it still shows the list — the fix is items=None plus self.items = [] if items is None else items.
Attributes created outside __init__
Demonstrates that an attribute assigned only inside another method does not exist until that method runs.
class Timer:
def __init__(self, label):
self.label = label
def start(self, t):
self.started_at = t
def elapsed(self, now):
return now - self.started_at
t = Timer("build")
print(t.label, vars(t))
try:
print(t.elapsed(10))
except AttributeError as e:
print("AttributeError:", e)
t.start(3)
print(t.elapsed(10), vars(t))Example explained
Line 1vars(t) shows only 'label', because __init__ made exactly one assignment to self.
Line 2elapsed fails with AttributeError since 'started_at' is not yet a key in the instance's __dict__.
Line 3Calling start(3) creates the attribute at that moment, mid-life of the object.
Line 4Adding self.started_at = None to __init__ would make the object's shape fixed and let elapsed raise a clearer error of your own choosing.
Important notes
__init__ is an initializer, not an allocator — the object is built by __new__ before __init__ is called, which is why self already exists on the first line.
You can attach attributes to an instance from anywhere, including outside the class, but code that relies on attributes not set in __init__ is fragile; declare them there with a placeholder value instead.
Common mistakes
Writing name = name instead of self.name = name: the assignment only rebinds a local variable inside __init__, so the object ends up with an empty __dict__ and every later obj.name raises AttributeError.
Using tags=[] or opts={} as a default: the container is created once at definition time and shared, so an append made by one instance shows up in every other instance built with the default.
Ending __init__ with return self (thinking it must hand the object back): Python raises TypeError: __init__() should return None, not 'Recipe' at construction time.
Try it yourself
Change, predict, then run
Write a Playlist class whose __init__ takes title and an optional tracks argument that defaults to None, builds a fresh list when none is given, and has an add(track) method. Create two playlists, add a track to one, and print vars() of both to show the lists are independent.
Open the Python workspaceCheck your understanding
A class sets only self.a = 1 in __init__, while a separate method bump() sets self.b = 2. What is true of a freshly created instance before bump() is called?
- vars(obj) is {'a': 1} and reading obj.b raises AttributeError
- vars(obj) is {'a': 1, 'b': None} because Python pre-registers every attribute assigned anywhere in the class
- obj.b returns None until bump() gives it a real value
- Reading obj.b raises NameError, since b is an undefined name
Show answer
An instance attribute exists only after an assignment to self actually executes, so the object's __dict__ holds just 'a' and attribute lookup for 'b' fails with AttributeError. Option 2 is tempting because the class body seems like a declaration, but Python never scans method bodies to reserve attribute slots; nothing exists until the line runs. NameError is for unbound plain names, not missing attributes.