Names, Scope, and LEGB
Names, Scope, and LEGB
A name in Python is looked up in a fixed order: Local, Enclosing, Global, Built-in (LEGB). The boring default is to pass values in and return values out. global and nonlocal exist; reach for them when a nested function must rebind a name, not as a way to share a desk-wide bag of state.
Mental model
- Local: names assigned in the current function.
- Enclosing: names in an outer function, if this
defis nested. - Global: names assigned at module level (the file).
- Built-in: names Python already has (
len,list,str,print).
Assignment makes a name local to that function, for the whole function, even if the = sits below a read. That is why count = count + 1 inside a function does not quietly update a module-level count unless you declared global count.
global name means “this assignment writes the module-level name.” nonlocal name means “this assignment writes the nearest enclosing function’s name.” Both are rebinding. Mutating a list you received as an argument does not need either keyword — you are not assigning to the name, you are changing the object.
Worked examples
Case 1: Local hides global
Save as shift_count.py. The function’s open_tickets is a new name. The module-level one is untouched.
# shift_count.py
open_tickets = 3
def start_shift():
open_tickets = 0
print("inside", open_tickets)
def main():
start_shift()
print("module", open_tickets)
if __name__ == "__main__":
main()Run:
uv run python shift_count.pyOutput:
inside 0
module 3
That is ordinary. The inner assignment never writes the outer name.
Case 2: global when you truly rebind module state
A flag at module level is sometimes real (a script-sized program). Declare it.
# desk_open.py
desk_open = False
def open_desk():
global desk_open
desk_open = True
def main():
print("before", desk_open)
open_desk()
print("after", desk_open)
if __name__ == "__main__":
main()Run:
uv run python desk_open.pyOutput:
before False
after True
Without global, open_desk would create a local desk_open and the module flag would stay False. Prefer returning a new value in anything larger than a script: desk_open = open_desk(desk_open).
Case 3: nonlocal for a nested counter
# ticket_ids.py
def make_id_factory(start):
next_id = start
def next_ticket_id():
nonlocal next_id
next_id += 1
return next_id
return next_ticket_id
def main():
next_id = make_id_factory(100)
print(next_id())
print(next_id())
print(next_id())
if __name__ == "__main__":
main()Run:
uv run python ticket_ids.pyOutput:
101
102
103
next_id += 1 is assignment, so Python would treat next_id as local to next_ticket_id unless nonlocal says otherwise. The enclosing make_id_factory keeps the counter. Two factories would keep two counters.
Case 4: Mutate without global
# queue_append.py
queue = []
def land(ticket):
queue.append(ticket)
def main():
land({"id": 7})
land({"id": 8})
print(queue)
if __name__ == "__main__":
main()Run:
uv run python queue_append.pyOutput:
[{'id': 7}, {'id': 8}]
queue.append does not assign to the name queue. LEGB finds the global list and mutates it. This works and it is still a slippery style: any function can change the queue. Passing the list in is clearer.
The trap
Shadowing a built-in. The name list in the local (or global) scope wins over the built-in constructor.
# shadow_list.py
def tables_on_shift(raw):
list = raw.split(",")
return list(int(t) for t in list)
def main():
print(tables_on_shift("3,11,12"))
if __name__ == "__main__":
main()Run:
uv run python shadow_list.pyOutput (the process exits non-zero):
Traceback (most recent call last):
File "shadow_list.py", line 12, in <module>
main()
File "shadow_list.py", line 8, in main
print(tables_on_shift("3,11,12"))
File "shadow_list.py", line 4, in tables_on_shift
return list(int(t) for t in list)
TypeError: 'list' object is not callable
list = raw.split(",") rebound the name. The next line tried to call a list. The fix is a real name: parts = raw.split(","), then return [int(t) for t in parts]. Do not name variables list, str, id, type, len, or dict.
A related trap: reading a global, then assigning to the same name later in the function. Python marks it local for the whole body and the early read raises UnboundLocalError.
The boring rule
- Pass arguments. Return results. That is the default.
- Use
globalonly for true module-level rebinding in a small script. - Use
nonlocalwhen a nested function must rebind an enclosing name (counters, small factories). - Mutating a list or dict through a name does not need
global. Still prefer passing the object in. - Never shadow built-ins.
Try this
- Remove
global desk_openfromdesk_open.pyand run. Then printdesk_openinsideopen_deskbefore the assignment and read theUnboundLocalError. - In
ticket_ids.py, make two factories (make_id_factory(100)andmake_id_factory(500)) and print a few ids from each. - Fix
shadow_list.pywith a name that is notlist. Run it.