You already know how to write a function that takes data and returns a result. That gets you a long way — most of the standard library is exactly that. But there’s a shape of problem where functions alone start to fray, and you can feel it happening.
It goes like this. You’re tracking bank accounts, so you keep a balance in a variable. Then you need the holder’s name, so that’s another variable. Then a transaction history — a third. Now you write deposit(balance, amount), and it has to return the new balance, and the caller has to remember to assign it. Then you need a second account, so now it’s two sets of three variables, or a dict of dicts, and every function starts with six lines of digging the right piece out. Somewhere in there you pass an account’s balance with a different account’s history, and nothing complains, because they’re just numbers and lists.
The problem isn’t any one of those steps. It’s that the data and the operations that belong to it have drifted apart, and nothing in the language is holding them together. A class holds them together.
This is lesson 1 of 4 on object-oriented programming, and it’s the foundation the other three stand on. We’re going to be unusually literal here: not “a class is a blueprint” hand-waving, but what actually happens, in order, when you type BankAccount("Vinod") — which function runs first, where your attributes physically live, and why self has to be there. Get this right and inheritance, encapsulation, and domain modelling are small additions. Get it fuzzy and they’re magic.
Everything below was run on Python 3.12. Type the snippets. Watching obj.__dict__ change on your own screen is what turns a rule into an instinct.
Why this matters
The honest case for OOP is narrower and more concrete than the way it’s usually sold. It is this: some data has behaviour that belongs to it, and nothing else.
A balance isn’t just a number. It’s a number with rules — it can’t go negative, deposits must be positive, and every change should be recorded. Those rules aren’t a property of integers; they’re a property of this balance in this account. If the rules live in free-floating functions, then nothing stops anyone from doing account_balance -= 5000 and skipping every check you wrote. The data has no way to defend itself, because the data doesn’t know the rules exist.
Bundling fixes that. When balance lives inside a BankAccount, and the only sane way to change it is account.withdraw(1000), the rule and the data occupy the same place. You get one home for “what is true about an account”, the same way a function gave you one home for “how do we compute an average” in Functions: Parameters, Return Values, Default & Keyword Arguments.
Here’s what that buys you, and what its absence costs:
| Bundling state + behaviour gives you | What that means in practice | What it costs you to skip it |
|---|---|---|
| One home for the rules | account.withdraw(x) is the only path in |
Every caller re-implements the “can’t go negative” check — badly, in two of them |
| Many independent copies | 500 accounts, each with its own balance | A dict-of-dicts you index by hand, and mismatched keys nobody catches |
| A vocabulary | account.statement() reads like the domain |
format_statement(holder, balance, history) — three args that must agree |
| Invariants that hold | Validation runs on every change, not when remembered | State goes bad silently; you find out at month-end |
| A named type | isinstance(x, BankAccount), real type hints |
Everything is dict[str, Any]; your editor can’t help you |
| Data that can’t be mismatched | The balance and its history travel together | Account A’s balance updated with account B’s history |
And now the part most tutorials skip, because it undercuts the pitch.
The honest anti-case
Python is multi-paradigm, and a class is often the wrong tool. This isn’t a grudging footnote — it’s a real design skill, and reaching for class reflexively is one of the most reliable marks of someone who learned OOP somewhere else and brought the habits along. Java and C# require a class to hold code; Python doesn’t. A module is already a namespace. A function is already a unit of reuse. You do not need to wrap them in ceremony.
The test that actually works: does this thing have state that several operations need to share, and does that state change over time? If yes, a class is earning its keep. If no, you’re writing a function with extra steps.
| Situation | Reach for | Why not a class |
|---|---|---|
| A calculation: input → output, no memory | A function | TaxCalculator().calculate(x) is calculate_tax(x) wearing a suit |
| A class with one method, called once | A function | If __init__ stores args and run() uses them, those are parameters |
| Bag of fields, no rules | A dataclass or NamedTuple |
You want __init__/__repr__/__eq__ free — write them by hand only to learn |
| Data you parse from JSON and read | A dict, or a dataclass | A class adds nothing over data["name"] if there’s no behaviour |
| Grouping related functions | A module | utils.py is already a namespace; a class of @staticmethod is a worse module |
| Config / constants | A module-level constant or dataclass | Config with only class attributes is a dict you can’t iterate |
| Real state + rules + many instances | A class ✅ | This is the case classes are actually for |
The TaxCalculator row is the classic tell, and it’s worth being blunt about. If you find yourself writing a class whose __init__ only stores its arguments and which has exactly one other method that uses them, you have written a function and given it a two-line preamble. calculate_tax(income, year) is better in every measurable way: fewer moving parts, no instance to construct, trivially testable, and the signature tells the whole truth.
Meanwhile the standard library is full of plain functions — len, sorted, sum, open — because that’s genuinely the right shape for them. Use a class when there’s state to protect. Otherwise write the function.
The mental model to carry through the whole lesson: a class is a factory that stamps out objects, and each object is a little bag of data that knows which functions apply to it. Everything else — self, __init__, attribute lookup — is mechanism serving that one idea.
Everything you have already used is an object
Before you write a class, notice that you’ve been using objects since your first line of Python. This isn’t a cute framing; it’s literally how the language is built, and it means class introduces far less new machinery than it appears to.
An integer has methods. A string has methods. Try it:
print((5).bit_length()) # => 3 — 5 is 0b101, so 3 bits
print((255).bit_length()) # => 8
print("kloudvin".upper()) # => KLOUDVIN
print((2.5).is_integer()) # => False
print((2.0).is_integer()) # => True
3
8
KLOUDVIN
False
True
Those parentheses around 5 aren’t decoration — they’re required, and the reason is a nice piece of trivia:
print(5.bit_length())
File "demo.py", line 1
print(5.bit_length())
^
SyntaxError: invalid decimal literal
Python’s tokeniser sees 5. and starts reading a float. Wrap it — (5).bit_length() — or just put it in a name first, which is what you’d do in real code anyway:
x = 5
print(x.bit_length()) # => 3 no parens needed once it has a name
Now the deeper point. type() tells you which class an object came from, and classes are themselves objects:
print(type(5)) # => <class 'int'>
print(type("kloudvin")) # => <class 'str'>
print(type(type(5))) # => <class 'type'>
<class 'int'>
<class 'str'>
<class 'type'>
Read <class 'int'> literally: the number 5 is an instance of a class called int, and int is itself an instance of a class called type. The turtles do stop — type is an instance of itself — but the point is that when you write class BankAccount: you are not entering a special walled-off part of the language. You’re building the same kind of thing int and str already are.
| Thing you’ve used | Its class | A method it has | What that proves |
|---|---|---|---|
5 |
int |
(5).bit_length() → 3 |
Even integers carry behaviour |
"kloudvin" |
str |
.upper() → KLOUDVIN |
Strings are objects; upper is a method |
[3, 1, 2] |
list |
.sort() → mutates in place |
Methods can change the object |
{"a": 1} |
dict |
.get("a") → 1 |
The dict you know is an instance |
2.5 |
float |
.is_integer() → False |
Floats too |
print |
builtin_function_or_method |
print.__name__ → 'print' |
Functions are objects |
int |
type |
int("42") → 42 |
Classes are objects you can call |
That last row is the hinge for everything below. int("42") is you calling a class, and getting an instance back. BankAccount("Vinod") is the exact same move. You already know how to instantiate — you’ve been doing it since list().
The class statement: a factory you write once
Here’s the smallest class that does anything:
class Server:
"""A single machine we care about."""
print(Server) # => <class '__main__.Server'>
print(type(Server)) # => <class 'type'>
print(Server.__name__) # => Server
print(Server.__doc__) # => A single machine we care about.
<class '__main__.Server'>
<class 'type'>
Server
A single machine we care about.
Server is now a name bound to a class object — a value, like any other. __main__ is just the module it was defined in (the script you ran).
The class body executes once, at definition time
This is the fact that dissolves the most confusion later, and almost nobody says it out loud: class is not a declaration. It’s a statement that runs. Python executes the indented body top to bottom, right where it appears, exactly once — then collects whatever names the body created and hands them to a new class object.
You can watch it:
print("--- before the class statement")
class Config:
print("--- inside the class body: this runs NOW, once")
ENV = "prod"
print(f"--- ENV is already {ENV} in here")
print("--- after the class statement")
print(Config.ENV)
c1 = Config()
c2 = Config()
print("--- made two instances; the body did NOT run again")
--- before the class statement
--- inside the class body: this runs NOW, once
--- ENV is already prod in here
--- after the class statement
prod
--- made two instances; the body did NOT run again
Look at the order. The body’s prints land between “before” and “after” — during the class statement itself, before a single instance exists. And ENV is readable inside the body as a plain local, because at that moment it is one.
Two consequences worth banking now:
- Every
definside a class body is an ordinary function definition. It runs at class-creation time and produces a plain function object, which gets stored under its name. There is nothing special about adefbecause it’s indented under aclass. - Anything expensive or mutable in a class body happens once, at import, and is shared by every instance forever.
class C: cache = {}is one dict for the whole program — the same trap as a mutable default argument, in a new costume.
| Part | What it is | Notes |
|---|---|---|
class |
The keyword | Executes the body, then builds a class object |
Server |
Class name | PascalCase by convention (PEP 8); functions stay snake_case |
: |
Colon | Required — forget it and you get SyntaxError: expected ':' |
| Indented body | A block that runs | Executes once, at definition time, in order |
"""...""" |
Docstring | First statement in the body → Server.__doc__, help(Server) |
def ... inside |
A plain function | Stored in Server.__dict__; becomes a method when accessed via an instance |
X = 1 inside |
A class attribute | One copy, shared by all instances |
pass |
Placeholder | For a body with nothing in it yet |
What Server("web01") actually does
Calling a class calls the class object, and two dunder methods run in a fixed order. Most tutorials say “__init__ is the constructor” and move on. That’s wrong in a way that matters, so let’s watch the real sequence:
class Traced:
def __new__(cls, *args, **kwargs):
print("1. __new__ runs first - it ALLOCATES a blank object")
instance = super().__new__(cls)
print(f"2. __new__ made a blank {type(instance).__name__}, __dict__ = {instance.__dict__}")
return instance
def __init__(self, name):
print(f"3. __init__ runs second - self already EXISTS, __dict__ = {self.__dict__}")
self.name = name
print(f"4. __init__ filled it in, __dict__ = {self.__dict__}")
t = Traced("web01")
print(f"5. Traced('web01') handed back the object: {t.name}")
1. __new__ runs first - it ALLOCATES a blank object
2. __new__ made a blank Traced, __dict__ = {}
3. __init__ runs second - self already EXISTS, __dict__ = {}
4. __init__ filled it in, __dict__ = {'name': 'web01'}
5. Traced('web01') handed back the object: web01
Read line 3 again: by the time __init__ runs, the object already exists. It’s blank — its __dict__ is empty — but it’s there, and it’s handed to __init__ as self. __init__ doesn’t build anything. It fills in an object somebody else built.
That’s why it’s an initialiser, not a constructor. __new__ is the closest thing Python has to a constructor: it’s the one that actually produces the object. You will almost never write __new__ — the default from object is what you want ~99% of the time, and it’s mostly seen in immutable subclasses and singletons. But knowing it’s there is what makes __init__'s signature make sense.
| Step | What runs | Gets | Returns | Do you write it? |
|---|---|---|---|---|
| 1 | Server("web01") |
Calls the class object | An instance | No — it’s the () |
| 2 | Server.__new__(Server, "web01") |
cls = the class |
A new blank instance | Rarely — inherit object.__new__ |
| 3 | Server.__init__(obj, "web01") |
self = that instance |
None, always |
Yes — this is your job |
| 4 | obj handed to the caller |
— | The initialised object | No — automatic |
__init__ is… |
__init__ is not… |
|---|---|
| An initialiser — fills in an existing object | A constructor — it doesn’t create anything |
Handed self as its first argument |
Responsible for returning the instance |
Required to return None |
Allowed to return self or any value |
Optional — omit it and you get object.__init__ |
The only way to set attributes (you can set them any time) |
| Run once per instantiation | Run when you access the object |
That “required to return None” row is enforced, and the error is unusually blunt:
class Bad:
def __init__(self):
return 42
Bad()
Traceback (most recent call last):
File "demo.py", line 5, in <module>
Bad()
TypeError: __init__() should return None, not 'int'
If you’re coming from a language where the constructor returns the object, that’s the rule to unlearn. Your __init__ sets attributes and returns nothing.
Now the real thing:
class Server:
def __init__(self, name, cpu):
self.name = name # store on THIS instance
self.cpu = cpu
web = Server("web01", 4)
db = Server("db01", 16)
print(web.name, web.cpu) # => web01 4
print(db.name, db.cpu) # => db01 16
print(isinstance(web, Server)) # => True
print(web.__class__.__name__) # => Server
web01 4
db01 16
True
Server
One class, one def, two completely independent objects. That’s the factory working.
self: the instance, handed over in the open
self is the single biggest stumbling block in this lesson, and it’s entirely because of how it’s usually explained. “self refers to the current instance” is true but useless — it makes self sound like a keyword the interpreter magically fills in.
Here’s the actual truth, and it’s much simpler: self is an ordinary parameter, and Python passes the instance to it as the first argument. That’s the whole mechanism.
The proof
obj.method() is sugar for Class.method(obj). Not “similar to” — the same call. Run this:
class Server:
def __init__(self, name):
self.name = name
def describe(self):
return f"server {self.name}"
s = Server("web01")
print(s.describe()) # the way you'll always write it
print(Server.describe(s)) # the way Python actually does it
print(s.describe() == Server.describe(s))
server web01
server web01
True
Server.describe(s) — reaching into the class, grabbing the plain function, and handing it the instance by hand — produces an identical result. It isn’t a trick or a debugging back door; it’s what the dot was doing for you all along.
You can go one level further and see the wiring:
print(type(s.describe)) # => <class 'method'>
print(type(Server.describe)) # => <class 'function'>
print(s.describe.__self__ is s) # => True
print(s.describe.__func__ is Server.describe) # => True
<class 'method'>
<class 'function'>
True
True
There it is, in the open. Server.describe is a plain function — that’s all a def in a class body ever creates. But s.describe is a bound method: a tiny object that packages the function together with the instance. It stores the instance in __self__ and the original function in __func__, and when you call it, it slots __self__ in as the first argument.
| Expression | Type | What it holds | Calling it |
|---|---|---|---|
Server.describe |
function |
Just the function from the class body | Server.describe(s) — pass the instance yourself |
s.describe |
method |
Function + the instance | s.describe() — instance passed for you |
s.describe.__func__ |
function |
The underlying function — is Server.describe |
Same object, no binding |
s.describe.__self__ |
Server |
The instance it’s bound to — is s |
— |
s.describe() |
— | — | Identical to Server.describe(s) |
This is why self must appear in the signature: something is going to be passed into that first slot whether you declared a parameter for it or not.
Why forgetting self gives that error
Now the traceback makes sense before you even read it:
class Broken:
def ping(): # no self!
return "pong"
b = Broken()
print(b.ping())
Traceback (most recent call last):
File "demo.py", line 6, in <module>
print(b.ping())
^^^^^^^^
TypeError: Broken.ping() takes 0 positional arguments but 1 was given
Beginners read that and think Python is being absurd — “but I passed zero arguments!” You did. Python passed one: b. b.ping() becomes Broken.ping(b), ping declared no parameters, and one argument has nowhere to go. The error is describing the call Python actually made.
The clincher — and this genuinely helps it stick — is that the same broken class works fine if you call it off the class, where no instance gets passed:
print(Broken.ping()) # => pong — works! It's just a function.
pong
Nothing is wrong with ping. It’s a perfectly good zero-argument function living in a class. It just can’t be called on an instance, because doing that supplies an argument it never asked for.
Version note: Python 3.9 words this as
TypeError: ping() takes 0 positional arguments but 1 was given— no class name. The qualifiedBroken.ping()form arrived in 3.10 and is a real improvement when three classes have aping.
self is a convention, not a keyword
Since self is just the first parameter, its name is up to you. Python doesn’t know the word:
class Weird:
def __init__(potato, name):
potato.name = name
def describe(potato):
return f"I am {potato.name}"
w = Weird("web01")
print(w.describe()) # => I am web01
I am web01
That runs. It’s also the last time you should ever do it. self is universal in Python — every linter, every tutorial, every colleague expects it, and pylint will flag potato immediately. The reason to know it’s possible is that it proves there’s no magic: no keyword, no interpreter hook, just a parameter that gets the instance because it’s first in line.
Attributes: where your data actually lives
Here’s the fact that makes Python objects click, and it’s less abstract than you’d expect: an instance is, essentially, a dictionary.
Not “like” a dictionary. It has one, it’s called __dict__, and you can read it:
class Server:
def __init__(self, name, cpu):
self.name = name
self.cpu = cpu
def describe(self):
return f"{self.name} ({self.cpu} vCPU)"
web = Server("web01", 4)
db = Server("db01", 16)
print(web.__dict__) # => {'name': 'web01', 'cpu': 4}
print(db.__dict__) # => {'name': 'db01', 'cpu': 16}
print(web.__dict__ is db.__dict__) # => False — separate dicts
{'name': 'web01', 'cpu': 4}
{'name': 'db01', 'cpu': 16}
False
self.name = name is not a declaration being satisfied. It is, near enough, self.__dict__["name"] = name. That’s why two instances never interfere: each has its own dict, and web’s name key has nothing to do with db’s.
The class has a dict too, and it holds something completely different — the methods:
print(list(Server.__dict__))
print([k for k in Server.__dict__ if not k.startswith("__")])
print(type(Server.__dict__))
['__module__', '__init__', 'describe', '__dict__', '__weakref__', '__doc__']
['describe']
<class 'mappingproxy'>
Two things to notice. __init__ and describe are in the class dict, not the instance dict — which is exactly right: one copy of the code, shared by every instance, while the data is per-instance. And the class dict is a mappingproxy, a read-only view; you can’t do Server.__dict__["x"] = 1. Use Server.x = 1 instead.
Version note: the dunder keys vary by version. Python 3.13 adds
__firstlineno__and__static_attributes__to that list. Filter to non-dunders (as in line 2) if you want output that’s stable across versions.
Attributes are not declared — they’re just keys
Because attributes are dict keys, you can add one whenever you like, to one object only:
web.region = "ap-south-1" # brand new attribute, after the fact
print(web.__dict__) # => {'name': 'web01', 'cpu': 4, 'region': 'ap-south-1'}
print(db.__dict__) # => {'name': 'db01', 'cpu': 16} — untouched
print(hasattr(db, "region")) # => False
{'name': 'web01', 'cpu': 4, 'region': 'ap-south-1'}
{'name': 'db01', 'cpu': 16}
False
Python let you invent region on one Server and not the other, and never said a word.
Is that good? Honestly: mostly no. It’s occasionally handy for a quick script or a debugging tag, but as a habit it’s how you end up with objects of the same class that have different shapes, and AttributeError in production because one code path set an attribute and another didn’t. The discipline that costs nothing: set every attribute an object will ever have in __init__, even if the value is None. Then the shape is knowable by reading one method. (Part 3 covers __slots__, which lets you actually forbid the surprise attribute.)
Ask for something that was never set and you get the error you’ll see more than any other in OOP:
print(db.region)
Traceback (most recent call last):
File "demo.py", line 6, in <module>
print(db.region)
^^^^^^^^^
AttributeError: 'Server' object has no attribute 'region'
And when it’s a typo rather than a genuine absence, 3.12 is kind:
print(web.nane)
AttributeError: 'Server' object has no attribute 'nane'. Did you mean: 'name'?
Version note: the
Did you mean:hint is 3.11+ and only fires when something is close enough —web.nmae(anm/atransposition) is too far and gets no suggestion. Python 3.9 never suggests. Don’t rely on it; read the name yourself.
The attribute API
Dot access is fine when you know the name while typing. When the name is computed at runtime — from a config file, a CSV header, user input — you need the function forms:
web = Server("web01", 4)
print(hasattr(web, "name")) # => True
print(hasattr(web, "region")) # => False
print(getattr(web, "name")) # => web01
print(getattr(web, "region", "unset")) # => unset — default, no crash
setattr(web, "region", "ap-south-1")
print(web.region) # => ap-south-1
delattr(web, "region")
print(hasattr(web, "region")) # => False
field = "cpu" # the point: name known only at runtime
print(getattr(web, field)) # => 4
True
False
web01
unset
ap-south-1
False
4
| Call | Equivalent to | Returns | Use when |
|---|---|---|---|
getattr(o, "x") |
o.x |
The value, or AttributeError |
Name is in a variable |
getattr(o, "x", d) |
— | The value, or d if missing |
Absence is legal — no try/except needed |
setattr(o, "x", v) |
o.x = v |
None |
Setting a computed name |
hasattr(o, "x") |
— | True / False |
Checking existence (it’s a getattr in a try) |
delattr(o, "x") |
del o.x |
None |
Removing an attribute |
vars(o) |
o.__dict__ |
The instance dict | Debugging: see all the data at once |
dir(o) |
— | list[str] of every name found |
Exploring an unknown object in the REPL |
o.__class__ |
type(o) |
The class object | Getting back to the class from an instance |
getattr(o, "x", default) deserves a callout: it is the clean way to handle “this attribute might not be there.” Reaching for try: o.x except AttributeError: when a three-argument getattr would do is a small tell that someone hasn’t met it yet.
Instance vs class attributes, and the lookup order
Assign a name in the class body and you get one value shared by everything:
class Server:
kind = "compute" # CLASS attribute — one copy, shared
def __init__(self, name):
self.name = name # INSTANCE attribute — one per object
web = Server("web01")
db = Server("db01")
print(web.kind, db.kind) # => compute compute
print(web.__dict__) # => {'name': 'web01'} — no 'kind' here!
compute compute
{'name': 'web01'}
Stop on that second line. web.kind returned "compute" — but kind is not in web.__dict__. The instance genuinely does not have it. Python went and found it somewhere else.
That somewhere else is the class, and the rule is the mental model everything in the next three lessons builds on:
Reading
obj.attrsearches the instance__dict__first, then the class__dict__, then each base class in turn. First hit wins. Nothing found →AttributeError.
Trace it for the three attributes above and the rule stops being abstract:
| Reading | 1· instance __dict__ |
2· class __dict__ |
3· bases (object) |
Result |
|---|---|---|---|---|
web.name |
hit — 'web01' |
not reached | not reached | 'web01' — stops at step 1 |
web.kind |
miss | hit — 'compute' |
not reached | 'compute' — from the class |
web.describe |
miss | hit — a function |
not reached | A bound method (see below) |
web.__class__ |
miss | miss | hit | <class 'Server'> |
web.region |
miss | miss | miss | AttributeError |
The web.describe row is the one that quietly ties this lesson together: a method is found by the same lookup as any other attribute. The only extra step is that when the walk finds a function on the class, Python binds it to the instance on the way back — which is where self comes from.
Watch all of it happen — including what “first hit wins” really implies:
web.kind = "gpu" # writes to the INSTANCE, shadowing the class
print(web.kind, db.kind) # => gpu compute
print(web.__dict__) # => {'name': 'web01', 'kind': 'gpu'}
print(Server.kind) # => compute — class untouched
del web.kind # remove the instance copy...
print(web.kind) # => compute — the class shows through again
gpu compute
{'name': 'web01', 'kind': 'gpu'}
compute
compute
web.kind = "gpu" did not change the class attribute. It created a new key in web’s own dict, which now shadows the class one — db never noticed, and Server.kind is untouched. Delete the instance key and the class attribute is visible again, like a curtain being pulled back.
This is where the read/write asymmetry bites, because it’s the one part that isn’t symmetrical:
| Operation | Where it looks / lands | Result |
|---|---|---|
Read obj.x |
instance dict → class dict → bases (MRO) | First hit wins; else AttributeError |
Write obj.x = v |
Always the instance dict | Never touches the class — creates/updates one key |
Write Class.x = v |
The class dict | Every instance without its own x sees the new value |
Delete del obj.x |
Only the instance dict | AttributeError if it was only ever on the class |
Read obj.method |
Found on the class → bound to the instance | You get a method, not the raw function |
The trap this creates has burned everyone once:
class Playlist:
tracks = [] # BUG: class attribute, shared by ALL instances
a = Playlist()
b = Playlist()
a.tracks.append("Rain") # MUTATES the shared list — no rebinding!
print(b.tracks) # => ['Rain'] ...on a different object
print(a.tracks is b.tracks) # => True
['Rain']
True
Why didn’t shadowing save us? Because a.tracks.append(...) is a read followed by a mutation, not a write. It reads a.tracks, finds nothing on the instance, walks up to the class, gets the one shared list, and appends to it. There’s no assignment anywhere, so no instance key is ever created. If you’d written a.tracks = ["Rain"] you’d have shadowed it and b would be fine — which makes this delightfully inconsistent-looking until you know the rule.
The fix is the same as the mutable-default fix from the functions lesson: mutable state belongs in __init__, so each instance builds its own.
class Playlist:
kind = "audio" # ✅ immutable, genuinely shared — fine on the class
def __init__(self):
self.tracks = [] # ✅ fresh list per instance
| Put it on the class when | Put it on the instance (__init__) when |
|---|---|
| It’s the same for every instance | It differs per object |
It’s immutable (str, int, tuple) |
It’s mutable (list, dict, set) — always |
It’s a constant: MAX_RETRIES = 3 |
It’s state that changes over the object’s life |
| It’s a default several instances share | It must be independent per object |
It’s a method (a def) |
— methods belong on the class, always |
Here’s the whole model in one picture. Read it left to right: the class statement runs once and builds a class object holding the methods, each call allocates an instance with its own __dict__ of data, reading an attribute walks instance-first-then-class, and finding a function on the class produces a bound method that passes the instance as self.
The six badges are the exact spots beginners trip: the class body runs once (1); __init__ initialises an object __new__ already made (2); each instance owns its data dict (3); lookup walks instance → class → bases (4); a miss is AttributeError (5); and obj.ping() is Server.ping(obj) (6).
Methods, and a first __repr__
A method is a function stored on a class. That’s the entire definition — you already proved it with type(Server.describe) → <class 'function'>. The only thing that makes it method-shaped is being reached through an instance, which binds self.
| Plain function | Method | |
|---|---|---|
| Defined | At module level | In a class body |
| Stored in | The module namespace | Class.__dict__ |
| First parameter | Whatever you need | self — the instance |
| Called | describe(server) |
server.describe() |
| Access to instance data | Only what you pass | Everything on self |
| Type via the class | function |
function — the same thing! |
| Type via an instance | — | method (bound) |
| Namespaced | No — describe competes globally |
Yes — Server.describe |
Methods call other methods through self
Inside a method, other methods are not in scope as bare names. They live on the class, and you reach them the same way you reach data: through self.
class Site:
def __init__(self, name):
self.name = name
def slug(self):
return self.name.lower().replace(" ", "-")
def url(self):
return f"https://{self.slug()}.internal" # method → method, via self
site = Site("Web One")
print(site.slug()) # => web-one
print(site.url()) # => https://web-one.internal
web-one
https://web-one.internal
Drop the self. and you get a NameError, not an AttributeError — because a bare slug() sends Python looking through local, enclosing, global and builtin scopes (the LEGB chain from the functions lesson). The class isn’t in that chain at all:
def url(self):
return f"https://{slug()}.internal" # BUG: bare name
Traceback (most recent call last):
File "demo.py", line 9, in <module>
Site("Web One").url()
File "demo.py", line 7, in url
return f"https://{slug()}.internal"
^^^^
NameError: name 'slug' is not defined. Did you mean: 'self.slug'?
Python 3.12 diagnoses this one perfectly — Did you mean: 'self.slug'? is exactly the fix. (Python 3.9 just says NameError: name 'slug' is not defined and leaves you to it.) The takeaway: a class body is not a scope that methods can see. Everything goes through self.
The object that prints like garbage
Make a class without a __repr__ and printing it is useless:
class Bare:
def __init__(self, name):
self.name = name
b = Bare("web01")
print(b)
print([b])
<__main__.Bare object at 0x102642540>
[<__main__.Bare object at 0x102642540>]
That’s the default object.__repr__: the class, and a memory address that changes every run and tells you nothing. In a list of twenty, it’s actively hostile — you can’t tell them apart.
__repr__ fixes it in one method:
class Server:
def __init__(self, name, cpu):
self.name = name
self.cpu = cpu
def __repr__(self):
return f"Server(name={self.name!r}, cpu={self.cpu!r})"
web = Server("web01", 4)
print(web) # => Server(name='web01', cpu=4)
print([web, Server("db01", 16)]) # => [Server(name='web01', cpu=4), Server(name='db01', cpu=16)]
print({"primary": web}) # => {'primary': Server(name='web01', cpu=4)}
Server(name='web01', cpu=4)
[Server(name='web01', cpu=4), Server(name='db01', cpu=16)]
{'primary': Server(name='web01', cpu=4)}
Note the !r inside the f-string — it applies repr() to each value, which is what puts the quotes around 'web01'. That’s not a cosmetic detail. The convention for __repr__ is: produce something that looks like the code which would rebuild the object. You can copy Server(name='web01', cpu=4) straight out of a log and paste it into a REPL. Without !r you’d get Server(name=web01, cpu=4), which you can’t.
__repr__ is the highest value-per-line method in Python. Three lines, and every debugging session, log line, and REPL poke for the life of that class gets better. Write it on every class you make.
__str__ vs __repr__
Two methods, two audiences: __repr__ is for developers (debugging, logs, the REPL); __str__ is for users (the message you’d show a human).
class Server:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Server(name={self.name!r})"
def __str__(self):
return f"server {self.name}"
s = Server("web01")
print(s) # => server web01 print uses __str__
print(repr(s)) # => Server(name='web01')
print(f"{s}") # => server web01 f-string default
print(f"{s!r}") # => Server(name='web01') !r forces __repr__
print([s]) # => [Server(name='web01')] containers ALWAYS use __repr__
server web01
Server(name='web01')
server web01
Server(name='web01')
[Server(name='web01')]
That last line is the one people trip over. print([s]) shows the repr, even though print alone shows the str — because the list is formatting its own contents, and containers always use repr on their items. It’s why a __repr__-less object stays ugly inside a list no matter how nice its __str__ is.
Memorise this table instead of guessing:
| What you write | Calls | Output above |
|---|---|---|
print(s) |
__str__ |
server web01 |
str(s) |
__str__ |
server web01 |
f"{s}" |
__str__ |
server web01 |
repr(s) |
__repr__ |
Server(name='web01') |
f"{s!r}" |
__repr__ — !r forces it |
Server(name='web01') |
s in the REPL |
__repr__ |
Server(name='web01') |
print([s]), print({s: 1}) |
__repr__ — containers always |
[Server(name='web01')] |
print(s) with no __str__ |
__repr__ — the fallback |
Server(name='web01') |
repr(s) with no __repr__ |
object.__repr__ — no fallback |
<__main__.Server object at 0x...> |
__repr__ |
__str__ |
|
|---|---|---|
| Audience | Developers | End users |
| Goal | Unambiguous — ideally eval-able |
Readable |
| Called by | repr(), the REPL, containers, f"{x!r}" |
str(), print(), f"{x}" |
| Fallback | None — object.__repr__ gives <... at 0x...> |
Falls back to __repr__ |
| Write it? | Always | Only if users see the object |
The asymmetry in that fallback row is the practical rule: define __repr__ and you get a sensible str() for free; define only __str__ and repr() stays garbage. So if you write exactly one, write __repr__.
== compares identity by default
One more default worth meeting now, because it surprises everyone:
class Server:
def __init__(self, name, cpu):
self.name = name
self.cpu = cpu
a = Server("web01", 4)
c = Server("web01", 4)
print(a == c) # => False !!
print(a is c) # => False
print(a == a) # => True
print(a.__dict__ == c.__dict__) # => True — the DATA is identical
False
False
True
True
Two objects with byte-identical data are not equal. By default, == on a custom class falls back to is — “are these the same object in memory?” — and they aren’t. That last line makes the point sharply: the data compares equal, the objects don’t.
Sometimes that’s what you want (two accounts with the same balance are still different accounts). Often it isn’t, and you fix it by writing __eq__ — which is Part 3’s territory, along with __hash__, why you can’t skip it if you want your objects in a set or as dict keys, and why @dataclass writes both for you. For now: know that == means is until you say otherwise.
Naming conventions you’ll see immediately
| Name | Means | Enforced? |
|---|---|---|
name |
Public — part of your API | — |
_name |
“Internal, don’t touch” — a convention | No. Nothing stops you |
__name |
Name-mangled to _Class__name |
Partly — avoids subclass collisions, not access |
__name__ |
A dunder: Python’s own protocol | Don’t invent your own |
Class |
PascalCase for class names |
Convention (PEP 8) |
method |
snake_case for methods/attributes |
Convention (PEP 8) |
The single leading underscore is the one you’ll use constantly — _validate in the next section says “this is plumbing, not for callers” without pretending to be private. Python has no private keyword and doesn’t want one; the convention is the mechanism. That whole discussion — properties, mangling, why “we’re all consenting adults here” — is Part 3.
Those __dunder__ names aren’t arbitrary either: they’re Python’s protocols. You don’t call them, you define them, and the language calls them for you when the matching syntax appears. Here are the ones this lesson touched, plus the two you’ll meet next:
| Dunder | Python calls it when | You write it? |
|---|---|---|
__init__ |
After C() builds the object — to initialise it |
Yes, nearly always |
__new__ |
C() — to allocate the object |
Rarely (immutables, singletons) |
__repr__ |
repr(), the REPL, containers, f"{x!r}" |
Yes, on every class |
__str__ |
str(), print(), f"{x}" |
Only if users see it |
__doc__ |
— it’s the docstring you wrote | Via """...""" |
__dict__ |
— it’s the attribute store itself | No — read it to debug |
__class__ |
— same as type(obj) |
No |
__eq__ |
a == b |
Part 3 — default is identity |
__hash__ |
hash(x), set/dict keys |
Part 3 — pairs with __eq__ |
The rule of thumb: define dunders, don’t call them. Write __repr__ and use repr(obj); write __init__ and use C(). Calling obj.__repr__() by hand works but marks you as fighting the language.
Worked example: a BankAccount that says no
Let’s put every piece together on something with real rules. The value of this example is that the class doesn’t just store a balance — it refuses to hold an invalid one.
Step 1 — validate in __init__. An object should never exist in a broken state, and __init__ is your one chance to guarantee that.
class BankAccount:
"""One customer's account. Amounts are whole rupees (int)."""
MIN_BALANCE = 0 # class attr: shared, immutable
def __init__(self, holder: str, opening_balance: int = 0) -> None:
if not holder.strip():
raise ValueError("holder must be a non-empty name")
if opening_balance < 0:
raise ValueError(f"opening_balance cannot be negative, got {opening_balance}")
self.holder = holder.strip() # instance attrs: per object
self.balance = opening_balance
self.history: list[str] = [] # fresh list per instance
Three decisions worth naming. Validation comes before any assignment, so a rejected account never half-exists. MIN_BALANCE is a class attribute because it’s shared and immutable — a genuine constant. And history is built in __init__, not defaulted in the signature and not put on the class, because it’s mutable — both of those routes give every account the same list.
Step 2 — __repr__ first, so debugging works from here on.
def __repr__(self) -> str:
return f"BankAccount(holder={self.holder!r}, balance={self.balance})"
Step 3 — a private helper the other methods share.
def _validate(self, amount: int) -> None:
if not isinstance(amount, int):
raise TypeError(f"amount must be a whole number of rupees, got {type(amount).__name__}")
if amount <= 0:
raise ValueError(f"amount must be positive, got {amount}")
The leading underscore says “internal”. Both deposit and withdraw need identical checks, so the rule gets one home — DRY applies inside a class exactly as it does between functions.
Note the exception types are chosen, not random: TypeError when the argument is the wrong kind of thing; ValueError when it’s the right type but an unacceptable value. That distinction is a real convention and interviewers do ask.
Step 4 — the behaviour, using self to reach both data and helper.
def deposit(self, amount: int) -> int:
self._validate(amount) # method calling method
self.balance += amount
self.history.append(f"+{amount}")
return self.balance
def withdraw(self, amount: int) -> int:
self._validate(amount)
if self.balance - amount < self.MIN_BALANCE:
raise ValueError(f"insufficient funds: balance {self.balance}, asked {amount}")
self.balance -= amount
self.history.append(f"-{amount}")
return self.balance
def statement(self) -> str:
lines = [f"{self.holder}: {self.balance}"]
lines.extend(f" {entry}" for entry in self.history)
return "\n".join(lines)
Notice self.MIN_BALANCE — read through the instance, and lookup finds it on the class. That’s deliberate: it means a future subclass (Part 2) can override MIN_BALANCE and withdraw will pick up the new value with no edit. Writing BankAccount.MIN_BALANCE would hard-code the base class’s value forever.
Step 5 — use it.
acct = BankAccount("Vinod H", 5000)
print(acct)
print(acct.deposit(2500))
print(acct.withdraw(1000))
print(acct.statement())
other = BankAccount("Asha R")
print(other)
print(other.history is acct.history) # separate lists?
BankAccount(holder='Vinod H', balance=5000)
7500
6500
Vinod H: 6500
+2500
-1000
BankAccount(holder='Asha R', balance=0)
False
And the rules actually bite:
for bad in [lambda: acct.withdraw(999999),
lambda: acct.deposit(-50),
lambda: acct.deposit(3.5),
lambda: BankAccount(" ")]:
try:
bad()
except (ValueError, TypeError) as exc:
print(f"{type(exc).__name__}: {exc}")
ValueError: insufficient funds: balance 6500, asked 999999
ValueError: amount must be positive, got -50
TypeError: amount must be a whole number of rupees, got float
ValueError: holder must be a non-empty name
| Guard | Raises | Why that type |
|---|---|---|
Empty holder |
ValueError |
Right type (str), unacceptable value |
Negative opening_balance |
ValueError |
Right type (int), unacceptable value |
deposit(3.5) |
TypeError |
Wrong kind of thing entirely |
deposit(-50) / deposit(0) |
ValueError |
Right type, meaningless amount |
withdraw(999999) |
ValueError |
Valid amount, invalid for this account’s state |
The honest limitation, since we’re being straight about what OOP does and doesn’t give you: none of this makes balance safe. Anyone can still write acct.balance = -99999 and walk straight past every check, because balance is a plain public attribute and Python has no private. That’s not a bug in the design — it’s the thing Part 3 solves, with @property and the underscore convention. What you have now is a class that makes the right path easy and obvious, which is most of the benefit.
Two side notes on the money. Real financial code stores integer paise (or uses Decimal), never floats, because binary floats can’t represent 0.1 — 0.1 + 0.2 really does give 0.30000000000000004 (the numeric types lesson has the full story). And isinstance(amount, int) accepts True, since bool subclasses int; deposit(True) would add ₹1. Both are the kind of edge that separates a demo from production.
Where this goes next
Everything in the rest of this OOP series is an extension of the one rule you now own — lookup walks instance → class → bases. Nothing later replaces it:
| Coming up | Extends what you learned | The connection |
|---|---|---|
| Part 2 — inheritance | The bases step of the lookup walk | class Savings(BankAccount) adds a link to the chain; C.__mro__ is the exact search order, and super() steps along it |
| Part 3 — encapsulation | Making acct.balance = -99999 impossible |
@property intercepts the lookup itself; _name, name mangling, __slots__, and __eq__/__hash__ |
| Part 4 — domain modelling | Deciding which classes should exist | Composition over inheritance, @dataclass, ABCs, and knowing when a function was the answer all along |
So resist the urge to jump ahead. If obj.__dict__ vs C.__dict__ and obj.m() == C.m(obj) are reflexes, Part 2 is a short lesson. If they’re fuzzy, super() will look like magic — because you’ll be guessing at the chain instead of reading it.
Hands-on lab
You’ll build Playlist from nothing: instantiate several, prove the self binding by hand, inspect both dicts, add a __repr__, then deliberately break it two ways and fix it.
Everything is standard library — no pip install — but a virtual environment is the right habit:
mkdir playlist-lab && cd playlist-lab
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python3 --version # want 3.12+ (Windows users: use `python`)
Step 1 — the class, with data and a __repr__. Create playlist.py:
"""playlist.py - one small class, built from scratch."""
class Playlist:
"""A named list of (title, seconds) tracks."""
kind = "audio" # CLASS attribute: one copy, shared by all
def __init__(self, name, owner="anon"):
self.name = name # INSTANCE attribute: one per object
self.owner = owner
self.tracks = [] # fresh list per instance - NOT a default arg
def __repr__(self):
return (f"Playlist(name={self.name!r}, owner={self.owner!r}, "
f"tracks={len(self.tracks)})")
What just happened: kind sits on the class (shared, immutable). name, owner and tracks are set on self, so each playlist owns them. tracks is built inside __init__ on purpose — a tracks=[] default, or tracks = [] in the class body, would share one list across every playlist.
Step 2 — methods, including one that calls another. Append (keep the indentation — these are inside the class):
def add(self, title, seconds):
"""Add one track. Returns self so calls can be chained."""
self.tracks.append((title, seconds))
return self
def total_seconds(self):
return sum(seconds for _title, seconds in self.tracks)
def duration(self):
total = self.total_seconds() # a method calling a method, via self
return f"{total // 60}m {total % 60}s"
def longest(self):
if not self.tracks:
return None
return max(self.tracks, key=lambda track: track[1])
What just happened: duration reaches total_seconds through self — bare total_seconds() would be a NameError. add returning self is the chaining trick you saw in Step 4’s .add(...).add(...).
Step 3 — the driver that proves the model. Append at module level (no indentation):
def main():
chill = Playlist("Deep Focus", owner="vinod")
gym = Playlist("Leg Day")
chill.add("Rain on Glass", 245).add("Slow Static", 190)
gym.add("Sprint", 132).add("Max Out", 208).add("Cooldown", 90)
print("1. repr :", chill)
print(" :", gym)
print("2. duration :", chill.duration(), "|", gym.duration())
print("3. longest :", chill.longest(), "|", gym.longest())
print("4. self proof :", chill.duration() == Playlist.duration(chill))
print(" bound to :", chill.duration.__self__ is chill)
print(" same func :", chill.duration.__func__ is Playlist.duration)
print("5. instance dict:", chill.__dict__)
print(" own data? :", chill.__dict__ is gym.__dict__)
print("6. class dict :", [k for k in Playlist.__dict__ if not k.startswith("__")])
print("7. kind (class):", chill.kind, "|", gym.kind)
print(" in instance?:", "kind" in chill.__dict__)
chill.kind = "podcast"
print(" after shadow:", chill.kind, "|", gym.kind, "| class:", Playlist.kind)
del chill.kind
print(" after del :", chill.kind)
print("8. no parens :", chill.duration)
print("9. getattr :", getattr(chill, "owner"), "|", getattr(chill, "genre", "unset"))
print(" hasattr :", hasattr(chill, "genre"))
if __name__ == "__main__":
main()
Step 4 — run it.
python3 playlist.py
1. repr : Playlist(name='Deep Focus', owner='vinod', tracks=2)
: Playlist(name='Leg Day', owner='anon', tracks=3)
2. duration : 7m 15s | 7m 10s
3. longest : ('Rain on Glass', 245) | ('Max Out', 208)
4. self proof : True
bound to : True
same func : True
5. instance dict: {'name': 'Deep Focus', 'owner': 'vinod', 'tracks': [('Rain on Glass', 245), ('Slow Static', 190)]}
own data? : False
6. class dict : ['kind', 'add', 'total_seconds', 'duration', 'longest']
7. kind (class): audio | audio
in instance?: False
after shadow: podcast | audio | class: audio
after del : audio
8. no parens : <bound method Playlist.duration of Playlist(name='Deep Focus', owner='vinod', tracks=2)>
9. getattr : vinod | unset
hasattr : False
That output is the entire lesson. Walk it:
- 1 —
__repr__earning its keep. Compare with<__main__.Playlist object at 0x104f3d250>, which is what you’d have without it. - 4 — the proof.
chill.duration()andPlaylist.duration(chill)agree,__self__ischill, and__func__is the plain function on the class.selfis just an argument. - 5 and 6 — the split. Data (
name,owner,tracks) is in the instance dict; the methods andkindare in the class dict. One copy of the code, N copies of the data. - 7 — lookup and shadowing.
kindreads fine offchillwhile not being inchill.__dict__. Assigning it creates an instance key that shadows the class;gymandPlaylist.kindnever move.delpulls the curtain back. - 8 — a method without
()is a bound method object, not a result. It even prints its instance, using the__repr__you wrote.
Step 5 — break it on purpose: forget self. Add this method inside the class and call it from main():
def shuffle(): # BUG: no self
return "shuffled"
print("10. shuffle :", chill.shuffle())
Traceback (most recent call last):
File "playlist.py", line 73, in <module>
main()
File "playlist.py", line 69, in main
print("10. shuffle :", chill.shuffle())
^^^^^^^^^^^^^^^
TypeError: Playlist.shuffle() takes 0 positional arguments but 1 was given
What just happened: chill.shuffle() became Playlist.shuffle(chill). You passed zero; Python passed one. Note the traceback has two frames — read it bottom-up: the error is on the chill.shuffle() line inside main, which was called from module level. The caret markers point at the exact failing call. Fix it — def shuffle(self): — and re-run. Then try Playlist.shuffle() on the broken version and watch it succeed: nothing is wrong with the function, only with calling it on an instance.
Step 6 — break it on purpose: AttributeError. Add to main():
print("11. genre :", chill.genre)
Traceback (most recent call last):
File "playlist.py", line 70, in <module>
main()
File "playlist.py", line 66, in main
print("11. genre :", chill.genre)
^^^^^^^^^^^
AttributeError: 'Playlist' object has no attribute 'genre'
What just happened: lookup checked chill.__dict__, then Playlist.__dict__, then object — no genre anywhere. Two fixes, and picking the right one is the skill: if every playlist should have a genre, set self.genre = genre in __init__ (the shape belongs in one place). If it’s genuinely optional, ask with a default: getattr(chill, "genre", "unset").
Now try a typo — chill.trakcs — and see 3.12 help:
AttributeError: 'Playlist' object has no attribute 'trakcs'. Did you mean: 'tracks'?
Step 7 — predict, then run. Each of these is a one-liner in main(). Guess first:
print(Playlist.tracks)— does the class havetracks?- Move
tracks = []from__init__up into the class body, then runchill.add("X", 10)and printgym.tracks. What appears, and why didn’t shadowing save you? print(Playlist.kind, chill.kind)afterPlaylist.kind = "video". Which instances change?- Delete
__repr__and re-run step 8. What does the bound method print now? print(vars(chill) == chill.__dict__)— same thing?
Common mistakes and troubleshooting
| Symptom / traceback | Cause | Fix |
|---|---|---|
TypeError: C.ping() takes 0 positional arguments but 1 was given |
Forgot self in the method signature |
def ping(self): — the instance is always passed as arg 1 |
TypeError: C.__init__() missing 1 required positional argument: 'name' |
Called C() without the args __init__ demands |
Pass them: C("web01"), or give the parameter a default |
TypeError: C.__init__() takes 2 positional arguments but 3 were given |
Passed more args than __init__ accepts (self is the +1) |
Count self: def __init__(self, name) takes one arg from the caller |
AttributeError: 'C' object has no attribute 'name' |
Never set, or set as name = name instead of self.name = name |
Assign through self; set every attribute in __init__ |
AttributeError: 'C' object has no attribute 'nane'. Did you mean: 'name'? |
Typo | Read the suggestion — 3.12 usually nails it |
NameError: name 'slug' is not defined. Did you mean: 'self.slug'? |
Called a sibling method as a bare name | self.slug() — a class body is not a scope methods can see |
Prints <bound method C.f of ...> |
Forgot the () — you printed the method object |
obj.f() |
Prints <__main__.C object at 0x104f3d250> |
No __repr__ |
Add def __repr__(self): return f"C(x={self.x!r})" |
TypeError: __init__() should return None, not 'int' |
returned a value from __init__ |
__init__ initialises and returns nothing — drop the return |
| All instances share one list/dict | def __init__(self, t=[]) — default built once at def time |
t=None, then if t is None: t = [] |
| All instances share one list/dict | tracks = [] in the class body — one copy for the class |
Move it into __init__: self.tracks = [] |
a == b is False for identical data |
Default == on a class is identity (is) |
Write __eq__ (Part 3), or use @dataclass |
TypeError: unhashable type: 'C' after writing __eq__ |
Defining __eq__ sets __hash__ = None |
Also define __hash__, or @dataclass(frozen=True) (Part 3) |
AttributeError: 'mappingproxy' object does not support item assignment |
Tried C.__dict__["x"] = 1 |
C.x = 1 |
AttributeError: type object 'C' has no attribute 'name' |
Read an instance attribute off the class: C.name |
obj.name — instance data doesn’t exist until an instance does |
TypeError: describe() missing 1 required positional argument: 'self' |
Called C.describe() off the class with no instance |
obj.describe(), or pass one: C.describe(obj) |
Three deserve extra words, because they cost the most hours.
1. The off-by-one in every TypeError. self makes every arity error read one higher than you expect. def __init__(self, name) “takes 2 positional arguments” — you supply one, Python supplies self. So Server("web01", 4) reports “takes 2 positional arguments but 3 were given” and you count your two arguments and conclude Python can’t add. It can; it’s counting self. Whenever an arity error is off by exactly one, self is the missing one — either you forgot to declare it, or you forgot it’s already there.
2. Forgetting self. when assigning — the silent one. This is the nastiest bug in the lesson, because it doesn’t raise anything where the mistake is:
class Server:
def __init__(self, name):
name = name # BUG: assigns a local parameter to itself. Stores nothing.
s = Server("web01")
print(s.__dict__) # => {} <- the object is EMPTY
s.name # AttributeError, one line later
{}
name = name is a perfectly legal statement — it rebinds a local to itself, does nothing, and vanishes when __init__ returns. Python has no idea you meant self.name. No error, no warning; the object is just born empty, and you find out somewhere else entirely when an AttributeError fires in a method that assumed the data was there. When an object mysteriously has no attributes, print obj.__dict__ immediately. An empty dict is this bug’s fingerprint.
3. Shared mutable state, by two different routes. Both of these give every instance the same list, and the second is the one unique to classes:
class A:
def __init__(self, tracks=[]): # route 1: mutable DEFAULT (evaluated once at def)
self.tracks = tracks
class B:
tracks = [] # route 2: CLASS attribute (one copy on the class)
Route 1 you may know from the functions lesson: the [] is built once when def runs, so every call that omits the argument gets that same list. Route 2 is new and sneakier, because shadowing looks like it should protect you. It doesn’t, and the reason is precise: b.tracks.append(x) never assigns anything. It reads b.tracks — miss on the instance, hit on the class, get the shared list — and mutates it in place. No assignment, no instance key, everyone affected. Write b.tracks = [x] and you’d shadow it and be fine, which is why this bug looks inconsistent until you know the rule. Immutable constants on the class; every mutable in __init__.
Cheat-sheet
| Syntax | What it does |
|---|---|
class C: |
Define a class — the body runs once, at definition time |
class C: + """doc""" |
Docstring → C.__doc__, help(C) |
obj = C() |
Instantiate: __new__ allocates, __init__ fills, object returned |
def __init__(self, x): |
Initialiser — fills an object that already exists; returns None |
def __new__(cls): |
The real constructor — allocates. You rarely write it |
self.x = value |
Set an instance attribute (≈ self.__dict__["x"] = value) |
x = value in class body |
Set a class attribute — one copy, shared by all instances |
def m(self): |
A method — self is required, it’s just the first parameter |
obj.m() |
Call it — sugar for C.m(obj) |
C.m(obj) |
The identical call, written out longhand |
obj.m |
A bound method object (no () = no call) |
obj.m.__self__ |
The instance it’s bound to — is obj |
obj.m.__func__ |
The underlying plain function — is C.m |
def __repr__(self): |
Developer view — repr(), REPL, containers. Write it always |
def __str__(self): |
User view — str(), print(), f"{x}". Falls back to __repr__ |
f"{self.x!r}" |
Apply repr() inside an f-string — how __repr__ gets its quotes |
obj.__dict__ / vars(obj) |
The instance’s data dict — your debugging first move |
C.__dict__ |
The class’s dict: methods + class attrs (a read-only mappingproxy) |
getattr(obj, "x") |
obj.x when the name is in a variable |
getattr(obj, "x", d) |
…with a default → no AttributeError |
setattr(obj, "x", v) |
obj.x = v for a computed name |
hasattr(obj, "x") |
Does it have it? |
delattr(obj, "x") / del obj.x |
Remove an attribute |
type(obj) / obj.__class__ |
The class an object came from |
isinstance(obj, C) |
Is it a C? (Prefer over type(obj) == C) |
C.__name__ |
The class’s name as a string |
C.__mro__ |
Lookup order: (C, object) — the basis of Part 2 |
dir(obj) |
Every name reachable on it — REPL exploration |
| Lookup order | obj.__dict__ → C.__dict__ → bases → AttributeError |
| Write rule | obj.x = v always writes to the instance, never the class |
_x |
“Internal” — convention only, nothing is enforced |
__x__ |
Dunder — Python’s protocol names. Don’t invent your own |
Interview and exam questions
Q: What’s the difference between a class and an object?
A: A class is the factory (a template that says what attributes and methods instances will have); an object, or instance, is one thing the factory stamped out. Server is the class; Server("web01") returns an object. One class, many independent instances — each with its own __dict__ of data, all sharing the class’s methods. And the class is itself an object: type(Server) is <class 'type'>.
Q: Is __init__ a constructor?
A: No — it’s an initialiser. By the time it runs, the object already exists: __new__ allocated it and passed it in as self. __init__'s only job is to fill in attributes on an object that’s already there, and it must return None — return 42 raises TypeError: __init__() should return None, not 'int'. __new__ is the closest thing to a constructor, and you almost never write it.
Q: What is self, really?
A: An ordinary parameter that receives the instance. obj.method() is sugar for Class.method(obj) — you can call it either way and prove they’re identical. The dot creates a bound method that stores the instance in __self__ and the function in __func__, then passes __self__ as the first argument. self isn’t a keyword; naming it potato works fine (and will get your PR rejected).
Q: Why do I get TypeError: C.ping() takes 0 positional arguments but 1 was given?
A: You wrote def ping(): without self, then called obj.ping(). That becomes C.ping(obj) — Python passes the instance, and the function declared nowhere for it to go. Add self. Sanity check: C.ping() on the same broken class works, because calling through the class passes no instance.
Q: Where do instance attributes actually live?
A: In the instance’s own __dict__. self.name = "web01" is essentially self.__dict__["name"] = "web01". Inspect it with obj.__dict__ or vars(obj). Methods live in C.__dict__ instead — one shared copy of the code, N copies of the data. Attributes aren’t declared; they’re just dict keys, which is why you can add one at runtime and why a typo raises AttributeError rather than being caught earlier.
Q: Explain the attribute lookup order.
A: Reading obj.attr searches the instance __dict__ first, then the class __dict__, then each base class along the MRO (C.__mro__). First hit wins; nothing found raises AttributeError. Writing is not symmetric: obj.attr = v always writes to the instance dict, never the class. That asymmetry is what “shadowing” is — an instance attribute hides a class attribute of the same name without altering it.
Q: What’s the difference between a class attribute and an instance attribute?
A: A class attribute is defined in the class body and there’s exactly one, shared by every instance. An instance attribute is set on self (normally in __init__) and each object has its own. Rule: immutable constants (MAX_RETRIES = 3) can live on the class; anything mutable must go in __init__, or every instance shares one list.
Q: This prints ['Rain']. Why — and why didn’t assigning to a protect b?
class Playlist:
tracks = []
a, b = Playlist(), Playlist()
a.tracks.append("Rain")
print(b.tracks)
A: tracks is a class attribute — one list on the class. a.tracks.append("Rain") is a read plus a mutation, not a write: it looks up tracks, misses on the instance, finds the shared list on the class, and appends in place. No assignment happens, so no instance key is created and b sees the change. If you’d written a.tracks = ["Rain"] you would have shadowed it and b would print []. Fix: self.tracks = [] in __init__.
Q: __str__ vs __repr__ — which do you write?
A: __repr__ is for developers (unambiguous, ideally looks like the code that rebuilds the object) and is used by repr(), the REPL, and containers — a list always prints its items’ reprs. __str__ is for end users and is used by print() and f"{x}". Key asymmetry: __str__ falls back to __repr__, but not vice versa. So if you write only one, write __repr__ — you get a decent str() free, and you never see <__main__.C object at 0x...> again.
Q: Why does a == b return False when both objects hold identical data?
A: The default __eq__ inherited from object compares identity, so == behaves like is — two separately-created objects are never equal, even though a.__dict__ == b.__dict__ is True. To compare by value, implement __eq__ (and __hash__ alongside it, since defining __eq__ alone sets __hash__ = None and makes instances unhashable), or use @dataclass, which writes both.
Q: When should you not write a class?
A: When there’s no state that several operations share and mutate. A class whose __init__ only stores its arguments and that has one other method using them is a function with extra steps — calculate_tax(income, year) beats TaxCalculator(income, year).calculate(). A bag of fields with no rules is a dataclass, a NamedTuple, or a dict. A group of related functions is a module, not a class of @staticmethods. Python is multi-paradigm; len and sorted are functions for good reason. Reach for a class when there’s real state to protect.
Q (coding): Write a Rectangle class with validation, a __repr__, an area(), and a method that uses another method.
A:
class Rectangle:
def __init__(self, width: float, height: float) -> None:
if width <= 0 or height <= 0:
raise ValueError(f"sides must be positive, got {width}x{height}")
self.width = width
self.height = height
def __repr__(self) -> str:
return f"Rectangle(width={self.width!r}, height={self.height!r})"
def area(self) -> float:
return self.width * self.height
def is_square(self) -> bool:
return self.width == self.height
def describe(self) -> str:
kind = "square" if self.is_square() else "rectangle" # method → method
return f"{kind} of area {self.area()}" # via self
r = Rectangle(3, 4)
print(r) # => Rectangle(width=3, height=4)
print(r.area()) # => 12
print(r.describe()) # => rectangle of area 12
print(Rectangle(5, 5).describe()) # => square of area 25
The graders are watching for four things: validation before assignment (so a bad object never exists), self. on every attribute, sibling methods reached via self. and not bare names, and a __repr__ using !r.
Key takeaways
- A class bundles state with the behaviour that acts on it — that’s the honest case, and it applies when data has rules and several operations share it. Otherwise write a function, a dataclass, or a module.
TaxCalculator(x).calculate()iscalculate_tax(x)in a costume, and Python is genuinely multi-paradigm. - You’ve always been using objects.
(5).bit_length()and"x".upper()work becauseintandstrare classes;type(5)is<class 'int'>andintis an instance oftype. Writingclass C:builds the same kind of thing —C()isint("42")with your own name on it. - The class body is a statement that runs, once, at definition time. Every
defin it becomes a plain function inC.__dict__; everyx = ...becomes a class attribute shared by all instances forever. Making instances never re-runs it. __init__is an initialiser, not a constructor.C()runs__new__(which allocates a blank object) and then hands that object to__init__asselfto fill in.__init__must returnNone.selfis just the first parameter.obj.method()isC.method(obj)— prove it by calling both. The dot builds a bound method with the instance in__self__and the function in__func__. Forgetselfand you get “takes 0 positional arguments but 1 was given”, because Python passed one you didn’t declare.- Instance data lives in
obj.__dict__; methods live inC.__dict__.self.x = vis nearlyself.__dict__["x"] = v. Attributes aren’t declared — they’re keys — so you can add one at runtime, and a missing one isAttributeError. Set every attribute in__init__anyway, and usegetattr(obj, "x", default)when absence is legal. - Lookup order is instance → class → bases; writes always land on the instance. That one rule explains shadowing, class attributes, and everything in Parts 2-4.
obj.x = vnever touches the class. - Any mutable on the class is shared by every instance —
tracks = []in the class body is the same bug asdef __init__(self, tracks=[]). Mutating through an instance is a read plus an in-place change, so shadowing never saves you. Immutable constants on the class; every mutable in__init__. - Write
__repr__on every class. Three lines, and every log line, REPL poke, and debugging session improves forever. Use!ron the values, and remember containers always print reprs —__str__falls back to__repr__, never the reverse. ==meansisuntil you say otherwise. Two objects with identical data aren’t equal by default. Fixing that — plus@property, real encapsulation, and__slots__— is Part 3; inheritance and the MRO the lookup chain hints at is Part 2.