Where a Name Actually Lives
A variable created inside a function looks like it should be visible everywhere once the function has run. Try to read it from outside, and find out Python disagrees, on purpose.
Read first: Naming a Piece of Work
A variable created inside a function looks like it should be visible everywhere once the function has run. Try to read it from outside, and find out Python disagrees, on purpose.
A name only exists where it was created
$ >>> def set_total():$ ... total = 100$ ...$ >>> set_total()$ >>> total$ NameError: name 'total' is not definedtotal was assigned, and the function ran without error. It still does not exist outside the function, because a name created inside a function belongs to that function's own local scope, and that scope disappears the moment the function returns.
This is not Python being restrictive for its own sake. Every call to set_total() gets a fresh local scope, discarded when the call ends — if it did not, a function that runs a thousand times would leave a thousand stale copies of every local name behind it, and no function could ever reuse a variable name safely.
The four places Python looks: LEGB
When code reads a name, Python does not search one place. It checks four scopes in order, and stops at the first one that has the name — an order usually remembered by its initials, LEGB.
Names assigned inside the current function.
Names in any function this one is nested inside, checked one level out at a time.
Names assigned at the top level of the module.
Names Python itself provides, like len, print, and range.
$ >>> value = "global"$ >>> def outer():$ ... value = "enclosing"$ ... def inner():$ ... print(value)$ ... inner()$ ...$ >>> outer()$ enclosinginner() has no local value of its own, so Python steps out one level to the enclosing function's value, finds it there, and stops — the module-level "global" is never even checked, because the search already succeeded at the enclosing scope.
Reading an outer name is allowed; changing it is not, by default
A function can read a name defined outside it without any special syntax. The moment it tries to assign to that name, Python assumes you meant to create a new local variable instead — even if a variable with that name already exists outside.
$ >>> count = 0$ >>> def increment():$ ... count = count + 1$ ...$ >>> increment()$ UnboundLocalError: cannot access local variable 'count'
Python sees the assignment count = count + 1 anywhere in the function body and decides, before the function even runs, that count is local to it. Then it tries to read that local count on the right-hand side before it has been assigned anything at all — hence the error.
nonlocal reaches into the enclosing scope, not all the way out
global only ever reaches the module level. A function nested inside another function needs a different keyword to modify a name in the scope one level out — nonlocal.
$ >>> def make_counter():$ ... count = 0$ ... def increment():$ ... nonlocal count$ ... count += 1$ ... return count$ ... return increment$ ...$ >>> counter = make_counter()$ >>> counter()$ 1$ >>> counter()$ 2Without nonlocal, count += 1 inside increment would hit the exact same UnboundLocalError as the previous section, for the same reason — the assignment makes Python treat count as local to increment, unless told otherwise. With it, each call to counter() reaches back into make_counter's scope and updates the same count that persists between calls.
Default arguments are evaluated once, not every call
A default value in a function signature is evaluated exactly once, when the function is defined — not fresh on every call. For a mutable default like a list, that single shared object gets reused across every call that relies on the default.
$ >>> def add_item(item, basket=[]):$ ... basket.append(item)$ ... return basket$ ...$ >>> add_item("apple")$ ['apple']$ >>> add_item("banana")$ ['apple', 'banana']The second call did not start from an empty basket. Both calls share the exact same list, created once when Python read the def line. The fix is to default to None and create a fresh list inside the function body when nothing was passed.
Mutating an argument changes it for the caller too; reassigning it does not
Python passes arguments by handing the function a reference to the same object the caller has — not a copy of it. What the function does with that reference determines whether the caller ever notices.
$ >>> def add_score(scores):$ ... scores.append(100)$ ...$ >>> my_scores = [88, 91]$ >>> add_score(my_scores)$ >>> my_scores$ [88, 91, 100]scores inside the function and my_scores outside it are two names pointing at the exact same list. .append() mutates that list in place, so the change is visible through either name — the function never had to return anything for the caller to see it.
$ >>> def add_score(scores):$ ... scores = scores + [100]$ ...$ >>> my_scores = [88, 91]$ >>> add_score(my_scores)$ >>> my_scores$ [88, 91]This time nothing changes outside the function. scores + [100] builds a brand new list, and scores = ... points the local name scores at it — my_scores, outside, still points at the original. Rebinding a name only ever affects that name, never the object it used to point at.
Key takeaways
- A name created inside a function belongs to that function and disappears when it returns.
- Python resolves a name by checking Local, then Enclosing, then Global, then Built-in scope, and stops at the first match — LEGB.
- Assigning to a name anywhere in a function makes Python treat it as local throughout the whole function, even before the assignment line runs.
- global reaches the module level; nonlocal reaches one enclosing function's scope. Prefer passing values in and returning them over reaching for either.
- Mutating an argument in place changes the object the caller sees too; rebinding the parameter name to a new object never does.
Quick check
Answer these to unlock the next chapter — 3 of 4 to pass. You can retake it anytime.
Answer every question to check.
Make a free account to read on
Every chapter is free — an account is how your progress, XP, and streak follow you from your laptop to your phone, and how you show up on the leaderboard. No payment, no trial.