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.

Toggle theme (T)