Scope

Local scope: variables inside a function stay inside it

A variable created inside a function doesn’t exist outside it:

Try it — edit and run

temperature only exists while set_temperature() is running, in its own local scope. This is true even if a variable with the same name exists outside the function — they’re unrelated:

Try it — edit and run

Reading an outer variable works — reassigning it doesn't, without global

A function can read a variable from outside it, as long as it doesn’t try to assign to it:

Try it — edit and run

But trying to reassign it inside the function, without saying so explicitly, doesn’t do what you’d expect:

Try it — edit and run

The moment Python sees an assignment to call_count anywhere in the function, it treats call_count as local to that function for its entire body — including the read on the right-hand side, before the local one has been created. The global keyword tells Python explicitly which one you mean:

Try it — edit and run

Why relying on global is usually a bad idea in agent code specifically

global works, but it makes a function’s behavior depend on state the function’s signature doesn’t reveal — you can’t tell what track_call() actually depends on just by looking at how it’s called. In agent code, this gets worse fast: if multiple parts of an agent loop mutate the same global variable (say, a shared conversation history or token count), it becomes very hard to reason about what state looks like at any given point, especially once things run concurrently. The far more common and safer pattern is to pass state in and return it out explicitly:

Try it — edit and run

Same result, but track_call’s dependency on call_count is now visible in its signature, not hidden behind a global statement elsewhere.

The mutable default argument trap

One specific scope-related gotcha worth knowing before it bites you: a default argument is evaluated once, when the function is defined — not once per call. This is harmless for immutable defaults like 0 or "hello", but dangerous for mutable ones like a list:

Try it — edit and run

The second call’s output likely surprises you: tools=[] isn’t a fresh empty list each time, it’s the same list object, created once when the function was defined, silently accumulating across every call that doesn’t pass its own. The standard fix is to default to None and create the list inside the function body instead:

Try it — edit and run

A third scope: closures

A function defined inside another function can “remember” variables from the outer function, even after the outer function has finished running:

Try it — edit and run

multiply is a closure: it “closes over” factor from make_multiplier’s scope. Each call to make_multiplier creates a separate factor, which is why double and triple don’t interfere with each other, even though both came from the same function.

This is exactly the shape of a tool factory — a function that returns a configured tool function without needing a class:

Try it — edit and run

nonlocal — the closure equivalent of global

Same problem as before: reading an enclosing variable works, but reassigning it needs an explicit keyword — nonlocal instead of global, since this is the enclosing function’s scope, not the module-level global scope. Two separate counters, run a different number of times, make it clear each one keeps its own independent state:

Try it — edit and run

counter_a and counter_b each came from their own call to make_counter(), so each has its own separate count — running one three times and the other five times doesn’t affect the other’s total at all. Without nonlocal, count += 1 would raise the same UnboundLocalError you saw earlier with global, for the same reason: Python would treat count as a new local variable the moment it sees an assignment to it.

How this relates to object-oriented code

If you’re used to reaching for a class whenever you need a “thing” that remembers state between method calls, a closure is doing the same job in miniature. Here’s make_counter rewritten as a class, for comparison:

Try it — edit and run

Line up the two versions: self.count in the class plays the same role as count remembered by the closure — both are state that persists across calls, private to one instance. increment(self) plays the same role as the inner increment() function — both are the one operation you can perform on that state. A closure is essentially a lightweight object with exactly one method and no name for its state — useful when you need “some remembered state plus one operation” and a full class would be more ceremony than the problem needs. Once you need multiple related operations sharing that state, a class (covered next lesson) is the better fit.

Check your understanding
1/8

After a function that creates a local variable finishes running, can that variable be accessed outside the function?

Exercise · Graded

Handle the case where registry is None by creating a fresh list inside the function, then add name only if it isn't already present, and return the registry.

Exercise · Graded

Define an inner function that takes a number and checks it against min_value/max_value from the enclosing scope, then return that inner function.