In the last lesson we met __init__. This one digs deeper — the difference between instance attributes and class attributes, how to type them properly, and some patterns to avoid.
Instance attributes — different for each object
Anything you assign to self.xxx is an instance attribute — belongs to that specific object:
class User:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
a = User("Alice", 30)
b = User("Bob", 25)
a.name = "Alicia"
print(a.name, b.name) # Alicia Bob
Changing a.name doesn’t affect b.name. They’re separate objects.
Class attributes — shared by all instances
Sometimes a value belongs to all instances of a class — a default, a counter, a registered list. Those go directly in the class body:
class User:
role: str = "user" # class attribute — shared default
def __init__(self, name: str) -> None:
self.name = name
a = User("Alice")
b = User("Bob")
print(a.role, b.role) # 'user' 'user'
# changing the class attribute affects every instance that hasn't overridden it
User.role = "guest"
print(a.role, b.role) # 'guest' 'guest'
# but if one instance sets its own, it gets its own copy
a.role = "admin"
print(a.role, b.role) # 'admin' 'guest'
This trips people up. The rule: reading goes to the instance first, then falls back to the class. Writing creates an instance-level copy.
Use class attributes for:
- Default values (
User.role = "user") - Constants associated with the class (
Circle.PI = 3.14159) - Shared registries (be careful!)
Mutable class attributes — the danger
This is the same trap as mutable default arguments (Section 5):
class Group:
members: list[str] = [] # SHARED across all Group instances!
def add(self, name: str) -> None:
self.members.append(name)
g1 = Group()
g2 = Group()
g1.add("Alice")
print(g2.members) # ['Alice'] — surprise!
The fix: initialise mutable state inside __init__:
class Group:
def __init__(self) -> None:
self.members: list[str] = [] # each instance gets its own list
def add(self, name: str) -> None:
self.members.append(name)
g1 = Group()
g2 = Group()
g1.add("Alice")
print(g2.members) # []
Type hints for attributes
There are two styles you’ll see:
# style 1 — annotated in __init__
class User:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
# style 2 — annotated in the class body (no value)
class User:
name: str
age: int
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
Both work. Style 2 is more explicit about “this class has these attributes” and is preferred when you have many fields. For small classes, style 1 is fine.
For class attributes with a default, use ClassVar:
from typing import ClassVar
class User:
role: ClassVar[str] = "user" # explicitly a class attribute
def __init__(self, name: str) -> None:
self.name = name
This tells pyright “role is shared, not per-instance”.
Conventions for attribute names
- Public — regular name:
self.name. Anyone can access it. - Internal — single underscore prefix:
self._cache. A signal: “this is implementation detail, please don’t touch from outside”. - Strongly private — double underscore prefix:
self.__secret. Python actually mangles the name to discourage access from subclasses. Rarely used.
Python doesn’t have real “private” attributes — it relies on convention. A _ prefix is the universal “leave this alone” marker.
Reading and writing attributes dynamically
You can access attributes by name as a string, when needed:
u = User("Alice", 30)
print(getattr(u, "name")) # 'Alice'
setattr(u, "name", "Alicia") # u.name = "Alicia"
print(hasattr(u, "email")) # False
delattr(u, "age") # del u.age
These are useful when the attribute name comes from data (a config, user input). Don’t use them when a regular u.name would do — they’re noisier.
repr — controlling how an object prints
By default, printing an object gives a useless <User object at 0x...>. Define __repr__ to control the output:
class User:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def __repr__(self) -> str:
return f"User(name={self.name!r}, age={self.age})"
u = User("Manikandan", 30)
print(u) # User(name='Manikandan', age=30)
print([u, u]) # [User(name='Manikandan', age=30), User(name='Manikandan', age=30)]
The !r inside the f-string applies repr() to the value, which adds quotes around strings.
Every class meant for real use should have a __repr__. It pays off the first time you have to debug a list of objects.
What’s next
You can store data on objects. Next, methods — the functions that act on that data.