Names, Scope, and LEGB

Updated

September 8, 2026

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 def is 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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

[{'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.py

Output (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 global only for true module-level rebinding in a small script.
  • Use nonlocal when 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

  1. Remove global desk_open from desk_open.py and run. Then print desk_open inside open_desk before the assignment and read the UnboundLocalError.
  2. In ticket_ids.py, make two factories (make_id_factory(100) and make_id_factory(500)) and print a few ids from each.
  3. Fix shadow_list.py with a name that is not list. Run it.