Intuition What this page is for
> The parent note taught you what __init__ is and how the call expands. This page does something different: it walks every distinct situation you can hit when writing a constructor, worked end-to-end. By the last example, you should never meet a constructor scenario you haven't already seen solved.
We treat a class exactly like a factory station : arguments come in on a belt, __init__ bolts them onto the fresh object called self, and the finished object rolls off. Different examples = different things arriving on the belt (missing parts, shared parts, bad parts, computed parts).
Definition A quick note on two notations used on this page
When you see a term wrapped in double equals (like self ), that is Obsidian's highlight — it just marks a key term you should be able to recall. It changes nothing about the code; it's a study aid.
When you see math wrapped in single dollar signs like r 2 , that is rendered mathematics (KaTeX). r 2 just means "r multiplied by itself", i.e. r ** 2 in Python. If your reader shows the dollar signs as plain text, read r 2 as "r squared" — the meaning is identical.
Before solving anything, let's enumerate every kind of input a constructor can face. Each row is a "cell". Every worked example below is tagged with the cell it covers, so you can see the whole space is filled.
Cell
Situation
What can go wrong / be special
A
All args supplied , plain copy
nothing — the baseline
B
Some args omitted → immutable default
default evaluated once, but immutable so safe
C
Some args omitted → mutable default
the shared-list trap
D
Computed attribute at init time
derived value, no matching parameter
E
Invalid input (out of range / wrong type)
must reject before object exists
F
Degenerate/zero input (empty, 0, None)
valid but edge-case; must not crash
G
__init__ chaining (super().__init__)
parent must also fill its parts
H
Real-world word problem
translate English → attributes
I
Exam twist (__init__ returns / no self.)
the trap questions
J
Keyword-only args (*, unit)
caller must name the argument
K
Varargs (*args, **kwargs)
flexible arity, collect extras
L
Wrong arity (too many/few args)
Python raises TypeError automatically
The figure below draws the matrix as a factory belt , so you can see the whole space of inputs at once. Read it left to right:
Orange arrow (left): the raw arguments arrive on the belt — this is whatever the caller passes inside Cls(...).
Violet box (middle): the __init__ station bolts those arguments onto self, the fresh blank object.
Magenta arrow (right): the finished, filled object rolls off — that's what the caller finally receives.
Twelve coloured chips along the belt: one chip per cell (labelled A through L). Each chip corresponds to exactly one worked example below. The figure's whole job is to show that the twelve chips tile the entire belt with no gaps — every situation a constructor can meet is a chip, and every chip is solved below.
Every example downstream names its chip (e.g. "Cell A"), so you can always trace an example back to its place on this belt.
Worked example A · Straight copy
class Point :
def __init__ (self, x, y):
self .x = x
self .y = y
p = Point( 3 , 4 )
print (p.x, p.y)
Forecast: what prints? Guess before reading on.
Point(3, 4) expands to o = Point.__new__(Point); Point.__init__(o, 3, 4); o. Here o is just a temporary name for the blank object that __new__ hands back — the empty shell before any attributes exist. It is the same object we will call self inside __init__.
Why this step? A class call always runs new-then-init; nothing is skipped.
Inside __init__ , self is that same o, x is the local 3, y is the local 4.
Why? Arguments fill parameters left-to-right after self, which is auto-supplied.
self.x = x copies the local 3 onto the object as an attribute named x.
Why this step? Locals vanish when __init__ ends; only object attributes survive.
self.y = y copies the local 4 onto the object as attribute y.
Why this step? Same reason as step 3 — each parameter we want to keep must be explicitly stored on self, one line per attribute.
Verify: p.x is 3, p.y is 4, so it prints 3 4. The object outlived the function — proof the data landed on self, not a local.
Worked example B · Safe default
class Account :
def __init__ (self, owner, balance = 0 ):
self .owner = owner
self .balance = balance
a = Account( "Ravi" )
b = Account( "Sita" , 500 )
print (a.balance, b.balance)
Forecast: does a and b sharing the same default 0 cause a bug?
balance=0 is evaluated once , when the def line runs, and stored on the function.
Why care? Defaults are NOT re-created per call — remember this for Cell C.
a = Account("Ravi") omits balance, so balance takes the default 0.
Why this step? An omitted argument with a default falls back to that default — this is how callers get to skip common values.
self.balance = 0 stores 0 on a.
Why this step? The default only filled the local balance; we still must copy it onto self for it to persist on the object.
b = Account("Sita", 500) passes 500, overriding the default.
Why this step? A supplied argument always wins over the default, so self.balance becomes 500 for b — proof the default is a fallback, not a fixed value.
Verify: prints 0 500. Sharing the default is harmless here because 0 is immutable — nobody can mutate the integer 0 in place, so a and b can never interfere.
Worked example C · The shared-list disaster
class Cart :
def __init__ (self, items = []): # DANGER
self .items = items
a = Cart()
a.items.append( "apple" )
b = Cart()
print (b.items)
Forecast: b is brand new — surely b.items is empty. Right?
items=[] is evaluated once at definition — one single list object is born and stored on the function.
Why this step matters: because it happens only once, every call that omits items reuses that same list rather than getting a new one.
a = Cart() → self.items points at the shared list.
Why this step? With items omitted, the local items becomes the one shared default list, and self.items = items makes a point straight at it — no copy is made.
a.items.append("apple") mutates the shared list in place → it now holds ["apple"].
Why this step? append changes the existing list object; it does not create a new one, so the mutation is visible to anyone else pointing at that same list.
b = Cart() omits items again → self.items points at the same shared list, already containing "apple".
Why this step? Since the default is the one list from step 1, b binds to the very object a just mutated — that is exactly why the "leak" happens.
Verify: prints ['apple'], NOT []. The bug is real. See the Mutable default argument pitfall .
Fix (the correct pattern):
class Cart :
def __init__ (self, items = None ):
self .items = items if items is not None else []
Now each object with no argument gets a fresh [] built inside the call, so b.items prints [].
None and not []?
None is immutable, so using it as the sentinel is always safe. The fresh list is created per call on the line else [], which runs every time — the whole point.
Worked example D · Derive area once per object
class Circle :
def __init__ (self, radius):
self .radius = radius
self .area = 3.14159 * radius ** 2
c = Circle( 5 )
print ( round (c.area, 3 ))
Forecast: there's no area parameter — where does c.area come from?
self.radius = radius stores the raw input on the object.
Why this step? area will be derived from radius, so we keep radius on self both as the source value and in case any later code needs it.
self.area = 3.14159 * radius ** 2 computes a new value from an existing one.
Why this step? area isn't given on the belt — it's derived . ** is exponentiation, so radius ** 2 is r 2 ("r squared"), and multiplying by π ≈ 3.14159 gives the disc's area.
This runs once per object, at the moment Circle(5) is initialized (not when the class line is defined) — and freezes the result onto that object.
Why here and not later? If radius never changes, recomputing on every access wastes work; compute it once during initialization. (If radius could change later, area would go stale — then use Properties and computed attributes instead.)
Verify: 3.14159 × 5 2 = 3.14159 × 25 = 78.53975 , rounds to 78.54. Units: if radius is cm, area is cm² — dimensionally sound.
Worked example E · Guard the gateway
class Temperature :
def __init__ (self, celsius):
if celsius < - 273.15 :
raise ValueError ( "Below absolute zero!" )
self .celsius = celsius
t = Temperature( - 300 ) # what happens?
Forecast: does t become an object with a weird value, or does something else happen?
__new__ already ran — a blank object exists in memory before __init__ starts.
Why this step? It explains why validation can still abort cleanly : the object exists but is unattached to any name, so if we bail out it simply gets garbage-collected and never reaches the caller.
The if check runs inside __init__: -300 < -273.15 is True.
Why check here? __init__ is the single gateway every object passes through — the perfect chokepoint to enforce an invariant (a rule that must always hold).
raise ValueError(...) aborts __init__ before self.celsius is ever set.
Why this step? By raising before the assignment, we guarantee no half-valid object with a bad temperature is ever produced — the failure is loud, not silent.
Because type.__call__ propagates the exception, the assignment t = ... never binds .
Why this step? The exception travels up through the class call, so t = Temperature(-300) throws instead of assigning — the name t is never created, proving no bad object escaped.
Verify: no valid Temperature object below absolute zero can exist. A legal call like Temperature(25) sets self.celsius = 25. Both branches are machine-checked in =VERIFY = (the "Cell E" item): it confirms -300 raises ValueError and that 25 builds an object with celsius == 25.
Worked example F · Empty and zero are still valid
class Stats :
def __init__ (self, numbers):
self .numbers = numbers
self .count = len (numbers)
self .total = sum (numbers)
self .mean = self .total / self .count if self .count else 0
s = Stats([]) # empty list — degenerate!
print (s.count, s.total, s.mean)
Forecast: an empty list has no mean — will this crash with divide-by-zero?
self.count = len([]) is 0.
Why this step? We record how many numbers we got before trying to divide, because the count is exactly what tells us whether a mean is even defined.
self.total = sum([]) is 0.
Why this step? sum of an empty list is the additive identity 0 — it never crashes, so total is always safe to compute even in the degenerate case.
self.mean = ... if self.count else 0 : since count is 0 (falsy), the if picks the else 0 branch.
Why this guard? Without it, 0 / 0 raises ZeroDivisionError. We handle the degenerate case explicitly so no input crashes.
Verify: prints 0 0 0. A non-degenerate call Stats([2, 4, 6]) gives count 3, total 12, mean 4.0 — both checked below.
Worked example G · Parent must fill its parts too
class Animal :
def __init__ (self, name):
self .name = name
class Dog ( Animal ):
def __init__ (self, name, breed):
super (). __init__ (name) # let Animal set self.name
self .breed = breed
d = Dog( "Rex" , "Labrador" )
print (d.name, d.breed)
Forecast: Dog.__init__ never writes self.name directly — will d.name exist?
Dog("Rex", "Labrador") runs Dog.__init__(d, "Rex", "Labrador").
Why this step? Because Dog defines its own __init__, that is the one Python runs — it does not automatically run Animal.__init__, which is exactly why step 2 is needed.
super().__init__(name) calls Animal.__init__(d, "Rex").
Why this step? The parent owns the name part of the assembly; skip this and self.name is never set. super() finds the parent and passes the same self automatically.
Inside Animal.__init__, self.name = "Rex" runs — on the very same d.
Why this step? Since super() forwarded the same object, the parent's assignment lands on d, not on some separate object — one object, filled by two __init__s.
Back in Dog.__init__, self.breed = "Labrador".
Why this step? The child adds the attribute the parent knows nothing about, completing the assembly — parent installs name, child installs breed.
Verify: prints Rex Labrador. Both attributes live on one object because both __init__s received the same self. (Forget the super() call and d.name would raise AttributeError.)
Worked example H · Translate English into attributes
Problem: A bank charges a one-time setup fee of 50 rupees on every new account. A customer opens an account with an initial deposit. Store the customer's name and their usable balance (deposit minus fee). Ravi deposits 1000 rupees. What is his usable balance?
(We write amounts in plain numbers of rupees — "rupees" is the currency of India; you can read every figure below as generic money units if you prefer.)
Forecast: guess Ravi's usable balance before coding.
class BankAccount :
SETUP_FEE = 50
def __init__ (self, name, deposit):
self .name = name
self .balance = deposit - BankAccount. SETUP_FEE
r = BankAccount( "Ravi" , 1000 )
print (r.balance)
Identify the nouns → attributes: "name" and "usable balance" become self.name, self.balance.
Why? Constructor attributes are the object's state ; the problem's nouns are that state.
Identify the rule → computation: usable = deposit − fee. This is a Cell-D style computed attribute.
Why this step? The problem defines "usable balance" in terms of two other numbers, so it must be derived inside __init__ rather than stored raw — exactly the pattern from Cell D.
SETUP_FEE = 50 is a class attribute (shared, same for all) — the fee isn't per-object data, so it belongs on the class, not self.
self.balance = 1000 - 50 .
Verify: 1000 - 50 = 950, prints 950. Units: rupees minus rupees = rupees. ✓
Worked example I(a) · Missing
self.
class Thing :
def __init__ (self, x):
x = x # no self!
t = Thing( 7 )
print ( hasattr (t, "x" ))
Forecast: does t have an attribute x?
x = x reassigns the local x to itself — a no-op on a local variable.
Why it's a trap: it looks like storing x, but there's no self., so nothing lands on the object.
When __init__ ends, the local x is discarded.
Why this step? Local variables live only for the duration of the call, so once __init__ returns, that x is gone and the object still has no attribute named x.
Verify: hasattr(t, "x") is False. Prints False. The fix is self.x = x.
Worked example I(b) · Returning from
__init__
class Thing :
def __init__ (self):
return 42 # illegal
try :
Thing()
ok = "no error"
except TypeError :
ok = "TypeError"
print (ok)
Forecast: does returning 42 just get ignored, or raise?
__init__ must return None. Returning any non-None value makes type.__call__ raise TypeError.
Why? The protocol reserves the return channel; the finished object comes from __new__, not __init__.
Verify: prints TypeError. (Returning bare return or return None is fine.)
Worked example J · Force the caller to name it
class Distance :
def __init__ (self, value, * , unit):
self .value = value
self .unit = unit
d = Distance( 10 , unit = "km" )
print (d.value, d.unit)
Forecast: would Distance(10, "km") (without unit=) work?
The lone * in the signature means "everything after me must be passed by keyword, never by position."
Why this tool? When an argument's meaning is easy to mix up (is "km" the value or the unit?), forcing unit="km" makes call sites self-documenting and prevents silent ordering bugs.
Distance(10, unit="km") supplies value=10 positionally and unit by name → both stored on self.
Why this step? value sits before the *, so it still accepts a positional 10; unit sits after the *, so it must be named — the call satisfies both rules and both land on self.
Distance(10, "km") would raise TypeError — the second positional slot doesn't exist, because unit is keyword-only.
Why this step? After the * there are no positional slots left, so a second positional argument has nowhere to go and Python rejects the call outright.
Verify: d.value is 10, d.unit is "km", prints 10 km. The rejection of the positional form is machine-checked below.
Worked example K · Collect however many parts arrive
class Playlist :
def __init__ (self, name, * songs, ** meta):
self .name = name
self .songs = list (songs) # *songs = a tuple of extra positionals
self .meta = meta # **meta = a dict of extra keywords
p = Playlist( "Roadtrip" , "SongA" , "SongB" , genre = "pop" , year = 2024 )
print (p.name, p.songs, p.meta[ "genre" ], len (p.songs))
Forecast: how many items land in p.songs, and what's in p.meta?
name grabs the first positional → "Roadtrip".
Why this tool? Sometimes you don't know how many items a caller will pass. *songs scoops all extra positional arguments into a tuple , so the constructor accepts any number of songs.
*songs collects the remaining positionals → the tuple ("SongA", "SongB"); we list(...) it so it's a mutable playlist.
Why this step? Tuples can't be appended to; converting to a list means songs can be added later — a playlist you can grow.
**meta collects any leftover keyword arguments into a dict → {"genre": "pop", "year": 2024}.
Why a separate **? * catches nameless extras (positional), ** catches named extras (keyword) — two different belts.
Everything is stored on self, so the object remembers all of it.
Why this step? As always, the collected tuple and dict are locals; copying them onto self is what makes them survive past the constructor.
Verify: p.songs is ["SongA", "SongB"] (length 2), p.meta["genre"] is "pop". Prints Roadtrip ['SongA', 'SongB'] pop 2. Checked below.
Worked example L · Python guards arity for free
class Pair :
def __init__ (self, a, b):
self .a = a
self .b = b
# Pair(1) -> TypeError: missing 'b'
# Pair(1, 2, 3) -> TypeError: too many arguments
Forecast: if you pass the wrong number of arguments, do you get a silent wrong object, or an error?
Pair(1) supplies too few — b has no value and no default.
Why does Python catch this? The interpreter matches arguments to parameters before your body runs; a missing required parameter is a TypeError, so no half-built object escapes.
Pair(1, 2, 3) supplies too many — there's no third slot to receive 3 (no *args), so again TypeError.
Why care? This is exactly why Cell K's *args/**kwargs exist: if you want flexible arity, you must opt in. Without them, arity is strict — a feature, not a bug.
Verify: both wrong-arity calls raise TypeError; the correct Pair(1, 2) sets a=1, b=2. All three are machine-checked below.
Recall Did we fill every cell?
A ::: Cell A (Point) · plain copy
B ::: Cell B (Account) · immutable default
C ::: Cell C (Cart) · mutable default trap + fix
D ::: Cell D (Circle) · computed attribute
E ::: Cell E (Temperature) · validation
F ::: Cell F (Stats) · empty/degenerate input
G ::: Cell G (Dog) · super() chaining
H ::: Cell H (BankAccount) · word problem
I ::: Cell I(a) missing self + I(b) illegal return
J ::: Cell J (Distance) · keyword-only argument
K ::: Cell K (Playlist) · *args and **kwargs
L ::: Cell L (Pair) · wrong arity raises TypeError
Parent — Hinglish version
OOP Fundamentals — the chapter these examples live in.
self and instance attributes — why every example writes self.x.
__new__ vs __init__ — why validation aborts after the shell exists (Cell E).
Class vs Instance Attributes — the SETUP_FEE in Cell H.
Properties and computed attributes — alternative to Cell D's frozen area.
Mutable default argument pitfall — the Cell C disaster in depth.
Dunder methods — __init__ is one of many.