Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Functional Python  ›  Lesson

Variable Scope & the LEGB Rule

Variable Scope 18 minHow Python resolves a name — Local, Enclosing, Global, Built-in — and the bugs that come from getting it backwards
Your task
Write priced_list(bases, override_rate=None). Build ONE region pricer: rate = override_rate if override_rate is not None else TAX_RATE (reads the module-level TAX_RATE), then an inner price_it(base) that reads `rate` from its enclosing scope. Return [price_it(b) for b in bases], each rounded to 2 decimals. Example: priced_list([1000]) -> [1180.0] · priced_list([1000], override_rate=0.0) -> [1000.0].

Variable Scope & the LEGB Rule

In short: every name lookup in Python checks four scopes, in this exact order: Local (the current call) → Enclosing (any function this one is defined inside) → Global (the module) → Built-in (len, sum, …). Python stops at the first scope that has the name. global and nonlocal don't create a name — they tell an assignment which existing scope to write into, instead of making a new local. Most scope bugs come from forgetting that a name read anywhere in a function makes Python search outward, but a name assigned anywhere in a function makes it local for the whole function, from the top.

Four boxes nested inside each other, Built-in outside Global outside Enclosing outside Local; a magnifying glass on the name "rate" starts in Local, finds nothing, steps out to Enclosing and stops there with a tick, never reaching Global or Built-in

A three-city pricing engine

A pricing tool has one module-wide default tax rate, but each region can override it. Build the override once per region, and let every price lookup inside that region read it — without ever touching the global directly:

python

price_it never mentions TAX_RATE. It asks for rate, and Python's search finds it one step out, in region_pricing — the Enclosing scope — and never even looks at the module. That's the order that trips people up: E is checked before G, not after.

What does this print?
LIMIT = 100

def make_checker():
    LIMIT = 50                 # a same-named local in the ENCLOSING function
    def over():
        return LIMIT           # which one does this find?
    return over

check = make_checker()
print(check())

A score tracker with a memory

A game keeps a running high score per session. nonlocal doesn't mean "global" — it means "the nearest enclosing scope that already has this name", which can be one level out or several:

python

wrap_round reads nothing and assigns nothing that's shared, so it needs no nonlocal at all — the rule only cares about names a function itself assigns. nonlocal climbs outward one enclosing scope at a time and stops at the first one that already defines the name; it never reaches the module level (that's what global is for).

broken — fix itUnboundLocalError

Run it and read the error. Then let record remember the best score between calls.

Shadowing a name you didn't mean to

A function that reuses a built-in's name doesn't fail right away — it fails later, somewhere that name is needed:

What happens when you run this?
def summarise(items):
    list = len(items)          # shadows the built-in list() inside this function
    empty = list([]) if list == 0 else "n/a"
    return empty

print(summarise([]))

Never name a variable the same as a built-in you (or code near it) still need: list, sum, id, type, input, dict are the usual victims.

Common scope bugs, named

  • UnboundLocalError from a read-before-write. If a function assigns to a name anywhere — even in an if that doesn't run — that name is local for the whole function, and reading it before the assignment line fails.
  • A for loop variable outlives the loop. Unlike many languages, Python has no block scope: for i in range(5): ... leaves i = 4 readable right after the loop, in the same scope. It does not leak into an enclosing or global scope though — only within the block's own function/module.
  • global/nonlocal must come before the first use in that function, and only affects the function it's written in — not functions it calls.

Quick practice

What happens when you run this?
x = "outer"

def show():
    print(x)
    x = "inner"

show()
What happens when you run this?
def counter_demo():
    n = 0
    def bump():
        n = n + 1     # looks like it updates n...
        return n
    return bump

tick = counter_demo()
print(tick())
broken — fix itUnboundLocalError

Make add actually accumulate across calls.

What does this print?
for i in range(3):
    last_seen = i

print(last_seen)

🎯 Your turn

Write priced_list(bases, override_rate=None). It builds one region pricer (like region_pricing above) and returns the price of every base amount in bases, each rounded to 2 decimals, at 18% tax by default — or at override_rate when one is given.

  • priced_list([1000]) → [1180.0]
  • priced_list([1000], override_rate=0.0) → [1000.0]

Hint: the module already has TAX_RATE = 0.18. Build one inner price_it closure that reads rate from its enclosing scope, then apply it to every base with a list comprehension: [price_it(b) for b in bases].

Press ▶ Run, try the Live App chips, then ✓ Check. After that, the practice set below goes from a single safe read to a full closures-and-globals tour.

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upRead a global, no keyword needed

A module-level SERVICE_FEE = 25 is already defined. Write total_with_fee(amount) that returns amount + SERVICE_FEE. You are only READING the global, so no global keyword is needed.

scopelegb
DrillA closure that remembers the best so far

Write make_running_max() returning a function check(x) that returns True when x is strictly higher than every value seen so far (or is the first value), else False. Then running_max_flags(values) builds ONE tracker and returns the list of flags for each value in order.

scopenonlocalclosures
BuildTwo generators, two separate scopes

Write make_id_generator(prefix) returning next_id(), which returns f"{prefix}-{n}" with n starting at 1 and going up by 1 on every call. Then batch_ids(prefix, count) builds ONE generator and returns a list of its first count ids.

scopenonlocalclosures
BossMutate a global vs. rebind it

A module-level LEDGER = [] holds entries. Write record_entry(amount) that appends amount to LEDGER (a mutation — no global needed) and reset_ledger() that empties it by REASSIGNING LEDGER = [] (an assignment — needs global). Then ledger_after(amounts) resets the ledger, records every amount in order, and returns the final ledger as a list.

scopeglobalmutation-vs-assignment
CapstoneOne closure, all four scopes

A module-level TRACKERS_MADE = 0 counts every tracker built during one tour. Write make_level_tracker() returning a pair (advance, status) sharing one enclosing level (starting at 1): advance() adds 1 to level and returns it (nonlocal); status() returns {"level": level, "trackers_made": TRACKERS_MADE} (reads level from Enclosing, TRACKERS_MADE from Global — no assignment, so no keyword there). Building a tracker must bump the global TRACKERS_MADE by 1 (needs global). Then tracker_tour(how_many, advances) resets TRACKERS_MADE to 0 (needs global too), builds how_many trackers in a row, calls advance() on the LAST one advances times, and returns its final status().

scopenonlocalgloballegb
priced_list.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
priced_list(bases, override_rate) → listPrice every base amount at 18% tax, or at override_rate when given — one closure built once and reused.
try:
returned
No app map for this section yet.
Press Run to execute your code.
Press Check to run the tests for this lesson.