The __init__ method and instance attributes are where a Python object actually gets its state. __init__ runs once per object at the moment it is created, and the self.x = value assignments inside it decide what that particular object will remember for its whole life.
Understanding those two mechanics explains several things that otherwise look arbitrary: why forgetting self raises an error, why changing a class attribute changes it for every object, and why two objects built from the same class never share state by accident.
When exactly does __init__ run?
It runs during construction, automatically, and it runs before you get a reference to the new object. You never call obj.__init__(...) yourself. The constructor you write — the class itself — takes the arguments you supply, builds the object, and hands control to __init__.
class Counter:
def __init__(self, start=0):
self.value = start
print("constructing two counters")
first = Counter(10)
second = Counter()
print("first.value :", first.value)
print("second.value:", second.value)
first.value = 99
print("after editing first:", first.value, second.value)
There is no return value to worry about. __init__ must return None implicitly, and returning anything else raises a TypeError. If you need to hand back a different object entirely, that is a different dunder method, __new__, and you almost certainly do not need it yet.
A useful detail: because __init__ runs before the constructor returns, the object it is building is not yet bound to any name in your code. That is why a mistake inside __init__ cannot leave you with a half-initialised variable — the exception propagates out of the constructor call and the assignment simply never happens.
Where does an attribute lookup actually look?
When you write obj.balance, Python searches in a fixed order. It checks the instance's own __dict__ first. If nothing is there, it checks the class. If the class does not have it either, it checks the class's own base classes. Only after all of that does it raise AttributeError.
That ordering is why assigning self.balance = 100 inside __init__ creates an instance attribute. The assignment always lands on the instance, never on the class.
class Account:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
savings = Account("Asha", 250)
current = Account("Ben", 90)
print("savings balance:", savings.balance)
print("current balance:", current.balance)
print("savings dict:", vars(savings))
print("current dict:", vars(current))
Here the two objects have different balances because each one wrote its own value into its own dictionary. Nothing was shared.
What is the difference between a class attribute and an instance attribute?
A class attribute is written directly in the class body, outside any method. It lives on the class and is shared by every instance that does not override it. An instance attribute is written with self. and lives on one object.
class Account:
currency = "GBP"
audit_trail = []
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def record(self, note):
self.audit_trail.append(note)
a = Account("Asha", 250)
b = Account("Ben", 90)
print("before:", a.balance, b.balance)
Account.balance = 50
print("instances with their own balance are unaffected:", a.balance, b.balance)
a.balance = 50
print("assigning on the instance changes only that one:", a.balance, b.balance)
a.record("opened")
b.record("checked")
print("the shared class list now holds:", Account.audit_trail)
print("and both instances see the same list:", a.audit_trail is Account.audit_trail)
That third line is the classic trap, and it works in two directions. Writing Account.balance = 50 reaches into the class and changes the shared value, but any instance that already set its own balance keeps its own number and shadows the class one. Meanwhile self.audit_trail.append(note) mutates the list that lives on the class, so every account sees every other account's history. That second one is the bug that actually bites people, because a list looks per-instance until you check where it was defined.
Writing acc.balance = 50 instead would change only that one account, because the assignment lands on the instance. The rule to remember: a class attribute holding a mutable object is shared state in disguise.
How do you validate data inside __init__?
Since __init__ is where the object gets its state, it is the right place to check that the state makes sense. Raise ValueError for bad input and the object never comes into existence in an invalid state.
class Rectangle:
def __init__(self, width, height):
if width <= 0 or height <= 0:
raise ValueError("width and height must both be positive")
self.width = width
self.height = height
def area(self):
return self.width * self.height
def scaled(self, factor):
return Rectangle(self.width * factor, self.height * factor)
print(Rectangle(3, 4).area())
print(Rectangle(3, 4).scaled(2).area())
try:
Rectangle(3, -1)
except ValueError as exc:
print("rejected:", exc)
Two details worth copying. The validation runs before any attribute is assigned, so a failed construction leaves nothing half-built. And the checks use raise rather than assert, because assert disappears entirely when Python runs with optimisations enabled — never use it to validate user input.
What about deleting attributes and giving them defaults?
del obj.attr removes an instance attribute, and hasattr(obj, "attr") tells you whether a name is reachable at all. A missing attribute raises AttributeError, which is often a sign you deleted something you should not have.
Defaults belong as ordinary parameters with default values in __init__, not as class attributes. Putting balance = 0 in the class body and then doing self.balance += 10 in __init__ works for numbers, but the moment the default is a mutable list or dictionary, every instance ends up sharing the same one. Give each instance its own copy instead.
Class attributes are still the right home for shared constants such as a currency code, a version string or a default that is genuinely the same for everyone. The test is simple: if two objects of the class could ever need different values for it, it belongs on the instance.
| Written as | Lives on | Shared between instances? |
|---|---|---|
currency = "GBP" in the class body | the class | yes, unless an instance shadows it |
self.currency = currency in __init__ | that one object | no |
def __init__(self, currency="GBP") | a parameter default | no, the default is evaluated per call |
Points worth keeping
__init__runs once per object at construction, and must not return a value.- Attribute lookup order is instance
__dict__, then the class, then base classes. - Assigning
self.x = valuealways creates or updates an instance attribute. - Class attributes are shared. Use them for constants, never for per-object state.
- Validate in
__init__withraise ValueErrorbefore assigning anything.
Try it yourself: a temperature class that refuses bad state
Write a Thermometer class whose __init__ takes a celsius value and a scale of either "c" or "f". Reject an unknown scale with ValueError. Store the temperature internally in Celsius regardless of the input scale, and expose a fahrenheit property that converts on demand.
Now create two thermometers, one in each scale, and check that they hold separate values. Then deliberately set the class attribute scale = "c" in the class body and confirm that adding self.scale = scale in __init__ is what makes each instance remember its own scale. Print vars() of both objects to see the difference.