Skip to content
aviral gupta

// I2.1 · ~30 min · Intermediate

Scopes and namespaces

After this lesson you can predict which variable a name refers to, change outer variables with global and nonlocal, and fix an UnboundLocalError.

Lesson 1 of 6 in I2 Classes

Start of the module

You will be able to

  • Predict which binding a name refers to, following the local, enclosing, global and built-in scopes
  • Rebind a module-level name with global and an enclosing name with nonlocal, and know when neither is needed
  • Explain why an assignment anywhere in a function makes a name local, and fix the UnboundLocalError it causes
  1. Warm-up · Activity 1 of 7

    Warm-up from module B3: a function assigns total = 0 in its body. Which statements are true? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: three functions, one name. What does this print?

    x = "global"
    
    
    def outer():
        x = "enclosing"
    
        def inner():
            return x
    
        return inner()
    
    
    print(outer(), x)
  3. Practice · Activity 3 of 7

    Fill in the keyword so that step() changes count in make_counter, and each call returns the next number.

    def make_counter():
        count = 0
    
        def step():
            ____ count
            count += 1
            return count
    
        return step
    count
  4. Practice · Activity 4 of 7

    No global statement this time. What does this print?

    names = []
    
    
    def add(name):
        names.append(name)
    
    
    add("ada")
    add("bob")
    print(names)
  5. Practice · Activity 5 of 7

    Match each line, written inside a function, to what it does.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The print comes before the assignment. What happens when show() runs?

    total = 10
    
    
    def show():
        print(total)
        total = 20
    
    
    show()
  7. Apply · Activity 7 of 7

    Mini-task. Write make_averager(), which returns a function add(value). Each call of add returns the average of every value passed so far: 10, then 20, then 60 give 10.0, 15.0 and 30.0. Keep the running total and count in make_averager, not in global variables.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A debug switch and ticket counters

Three ways a function reaches a name outside itself. enable_debug and log rebind module-level names with global. make_counter returns a closure that keeps its own count with nonlocal, so the two counters do not share a number. The last log call reads len, which Python finds in the built-in scope.

main.py

from collections.abc import Callable

# Three ways a function reaches a name outside itself.
DEBUG = False
calls = 0


def enable_debug() -> None:
    global DEBUG
    DEBUG = True


def log(message: str) -> None:
    global calls
    calls += 1
    if DEBUG:
        print(f"[{calls}] {message}")


def make_counter(start: int) -> Callable[[], int]:
    count = start

    def step() -> int:
        nonlocal count
        count += 1
        return count

    return step


log("not shown: debug is off")
enable_debug()
log("debug is on")

tickets = make_counter(100)
orders = make_counter(0)
print(tickets(), tickets(), orders())
log(f"len is still the built-in: {len('abc')}")
print("calls:", calls)

Run it with

python main.py

Output

[2] debug is on
101 102 1
[3] len is still the built-in: 3
calls: 3
  • The first log call is counted but prints nothing, so the first line shown is [2].
  • log reads DEBUG without declaring it: reading a global needs no global statement.
  • tickets and orders each have their own count, because every call of make_counter makes a new one.
  • Callable[[], int] is the type of a function that takes no arguments and returns an int.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

A page-view counter

record(page) should add one to the module-level views, append the page to history and return the new views. reset() should set views back to 0 and empty history. Both functions are broken: record raises UnboundLocalError, and reset leaves views unchanged. Fix them with the right declaration, and keep history.append and history.clear as they are.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Both functions assign to views, so Python treats views as local in each of them.

  2. Hint 2

    Add global views as the first line of record and of reset.

  3. Hint 3

    history needs no declaration: append and clear change the list without rebinding the name.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

views = 0
history: list[str] = []


def record(page: str) -> int:
    """Count one view of page and return the total so far."""
    global views
    views += 1
    history.append(page)
    return views


def reset() -> None:
    """Set the count back to 0 and forget the history."""
    global views
    views = 0
    history.clear()
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

views = 0
history: list[str] = []


def record(page: str) -> int:
    """Count one view of page and return the total so far."""
    views += 1
    history.append(page)
    return views


def reset() -> None:
    """Set the count back to 0 and forget the history."""
    views = 0
    history.clear()

test_main.py

import main
from main import record, reset


def test_record_counts():
    """record returns 1, then 2, after a reset"""
    reset()
    got = record("/home"), record("/about")
    assert got == (1, 2), f"record returned {got!r}, expected (1, 2)"


def test_module_count():
    """The module-level views changes too"""
    reset()
    record("/home")
    assert main.views == 1, f"main.views is {main.views!r} after one record, expected 1"


def test_history():
    """record keeps the pages in order"""
    reset()
    record("/a")
    record("/b")
    assert main.history == ["/a", "/b"], f"main.history is {main.history!r}, expected ['/a', '/b']"


def test_reset():
    """reset sets views back to 0 and empties history"""
    reset()
    record("/a")
    reset()
    assert main.views == 0, f"main.views is {main.views!r} after reset, expected 0"
    assert main.history == [], f"main.history is {main.history!r} after reset, expected []"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A rate limiter

make_limiter(limit) returns a function allow(). allow() returns True for the first limit calls and False for every call after that. Each limiter keeps its own count: two limiters must not share one. Keep the count in make_limiter and change it from allow with nonlocal. No global variables.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    allow has to change used, which belongs to make_limiter, the enclosing function.

  2. Hint 2

    Start allow with nonlocal used. Then compare used with limit.

  3. Hint 3

    if used >= limit: return False. Otherwise add one to used and return True.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from collections.abc import Callable


def make_limiter(limit: int) -> Callable[[], bool]:
    used = 0

    def allow() -> bool:
        nonlocal used
        if used >= limit:
            return False
        used += 1
        return True

    return allow
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from collections.abc import Callable


def make_limiter(limit: int) -> Callable[[], bool]:
    used = 0

    def allow() -> bool:
        # Return True for the first `limit` calls, False after that.
        return True

    return allow

test_main.py

from main import make_limiter


def test_first_calls_allowed():
    """The first three calls of a limit-3 limiter are allowed"""
    allow = make_limiter(3)
    got = [allow() for _ in range(3)]
    assert got == [True, True, True], f"the first three calls returned {got!r}"


def test_then_refused():
    """Calls after the limit are refused"""
    allow = make_limiter(2)
    got = [allow() for _ in range(4)]
    assert got == [True, True, False, False], f"four calls with limit 2 returned {got!r}"


def test_separate_counts():
    """Two limiters keep separate counts"""
    a = make_limiter(1)
    b = make_limiter(1)
    got = a(), a(), b()
    assert got == (True, False, True), f"a(), a(), b() returned {got!r}, expected (True, False, True)"


def test_zero_limit():
    """A limit of 0 refuses the first call"""
    allow = make_limiter(0)
    got = allow()
    assert got is False, f"with limit 0 the first call returned {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Changing a global without declaring it

count = 0


def increment():
    count += 1


increment()

What Python prints

UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

Why, and the fix

count += 1 is an assignment, so count is local in increment, and the local has no value to add to. If you really mean the module-level count, write global count as the first line of the function. Often better: pass the value in and return the new one, count = increment(count).

nonlocal for a module-level name

total = 0


def add(n):
    nonlocal total
    total += n

What Python prints

SyntaxError: no binding for nonlocal 'total' found

Why, and the fix

nonlocal only looks in enclosing functions, and add is not inside another function: total is a global. Python rejects the file before running anything. Use global total for a module-level name, and keep nonlocal for closures.

Hiding a built-in with your own name

list = [3, 1, 2]
letters = list("abc")

What Python prints

TypeError: 'list' object is not callable

Why, and the fix

The global name list now refers to your list, and the global scope is searched before the built-ins, so list("abc") tries to call a list. Pick another name, such as numbers. The same happens with str, dict, sum, max and id. In an interactive session, del list removes your global and brings the built-in back into view.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Namespaces and the LEGB search

A namespace maps names to objects. A module has one for its global names, every function call gets a fresh one for its local names, and the built-ins such as len and print live in another. When code reads a name, Python searches the scopes in a fixed order: Local (the current function), Enclosing (outer functions, nearest first), Global (the module), then Built-in. The first hit wins, so a local name hides a global of the same name, and a global called len hides the built-in. Scopes follow where the code is written, not where a function is called from.

global and nonlocal rebind outer names

Assigning to a name inside a function creates a local name and leaves the outer one alone. To rebind a module-level name, declare it first: global count. To rebind a name of an enclosing function, write nonlocal count; that is how a closure keeps a counter between calls. nonlocal only looks in enclosing functions, never in the module. Neither is needed to change a mutable object: items.append(x) changes the list without rebinding items. Use global sparingly: passing values in and returning results keeps functions easy to test.

An assignment anywhere makes a name local

Python decides which names are local when it compiles a function, not line by line. If the body assigns to x anywhere, with =, +=, a for loop or an import, x is local in the whole function, even above that line. Reading it before it has a value raises UnboundLocalError: cannot access local variable 'x' where it is not associated with a value. UnboundLocalError is a subclass of NameError. Fix it with a global or nonlocal declaration when you mean the outer name, or with a different local name when you do not.

Sources

Last reviewed September 29, 2026