Instance vs. class variables

Class variables — defined once, shared by everyone

An instance attribute (self.x = ... inside __init__) belongs to one object. A class variable is different: it’s defined directly in the class body, not inside __init__, and every instance shares the same one — closer to a static field in Java/C#, except there’s no static keyword. The variable just lives in the class body instead of a method:

Try it — edit and run

Both instances read the exact same default_model — there’s only one copy in memory, not one per instance. You can access it through the class name directly too, which makes clear it doesn’t belong to any particular instance:

Try it — edit and run

Reading vs. reassigning — the shadowing trap

Reading a class variable through self works fine, since Python looks it up on the instance first, then falls back to the class if it’s not found there. But assigning to self.x doesn’t modify the class variable — it creates a brand-new instance attribute that shadows it, for that instance only:

Try it — edit and run

agent_a.default_model = "claude-haiku" doesn’t touch the shared class variable at all — it creates a new instance attribute on agent_a that happens to have the same name, which now shadows the class variable whenever you look it up through agent_a specifically. agent_b and Agent itself are completely unaffected. This is the same shadowing logic as reassigning a variable inside a function creating a new local instead of touching the outer one — just one level up, instance vs. class instead of local vs. global.

The mutable class variable trap

This shadowing behavior is mostly harmless for immutable values like strings or numbers — you get a separate copy, no real damage done. It gets genuinely dangerous when the class variable is a mutable type, like a list, and you mutate it in place instead of reassigning it. Mutating in place (.append(), not =) never triggers the shadowing above — every instance is still pointing at the exact same shared list:

Try it — edit and run

agent_b never called add_tool, but it sees "search" anyway — both instances share the exact same list object, so mutating it through one instance is visible through every other one too. This is the same failure shape as the mutable default argument trap: a single mutable object created once and silently shared everywhere that doesn’t explicitly get its own copy.

The fix is the same instinct as that trap’s fix — give each instance its own copy, created fresh in __init__, not shared at the class level:

Try it — edit and run

When a class variable is actually the right call

None of this means class variables are a mistake to avoid — they’re the right tool in two specific cases:

A genuine constant, the same for every instance and never meant to change per-object:

class Agent:
    DEFAULT_TEMPERATURE = 0.7   # ALL_CAPS is the usual convention for a constant

    def __init__(self, name: str, temperature: float = None):
        self.name = name
        self.temperature = temperature if temperature is not None else Agent.DEFAULT_TEMPERATURE

(not run live — illustrating the pattern, not a new output)

A value genuinely meant to be shared and tracked across every instance — like a running count of how many agents have been created. The key detail: modify it through the class name, not self, or you’ll hit the exact shadowing trap from above:

Try it — edit and run

Agent.total_created += 1 explicitly targets the class variable — writing self.total_created += 1 instead would silently create a separate, shadowed instance attribute on each agent, starting over at a wrong value every time, instead of actually incrementing a shared count.

Check your understanding
1/5

What's the key difference between a class variable and an instance attribute?

Exercise · Graded

Add a class variable total_calls starting at 0. In __init__, store tool_name, increment ToolCall.total_calls, and save the resulting count as an instance attribute so call_number() can return it later. Each new ToolCall should see the running total from every ToolCall created before it, including ones from previous test cases.