skip to content

How does a Python function's `__code__.co_varnames` prove which names the compiler made local?

level: middleimportance: nice to knowfreq 22%

answer

  1. Functions carry a compiled code object
  2. The decision is data, not a traceback
  3. One tuple lists slots, another lists globals
  4. Parameters come first in that tuple
  5. co_varnames versus co_names

basics

~20 s

A function's compiled code object, reachable as code, lists in co_varnames exactly the names the compiler gave local slots. A name there is local on every line of the body; names resolved as globals appear in co_names instead.

solid answer

~40 s

Scope is decided when the `def` is compiled, so the decision is inspectable without running the function. `f.__code__.co_varnames` is the tuple of local names — parameters first, then other locals — and `f.__code__.co_nlocals` is its length. Names the function reads from the module or builtins namespace appear in `f.__code__.co_names` instead. So for a body that assigns `x` anywhere, `co_varnames` contains `'x'` even if the offending read is on line one; add `global x` and `'x'` moves out of `co_varnames` and into `co_names`. Disassembly makes the same point at the instruction level: a local read compiles to a fast slot load and a global read to a global load. The `symtable` module answers the same question from source text, without importing the module.

code

python · 13 lines
python
x = "global"

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

def show_global():
    global x
    print(x)

print(show.__code__.co_varnames, show.__code__.co_names)
print(show_global.__code__.co_varnames, show_global.__code__.co_names)
print(show.__code__.co_nlocals, show_global.__code__.co_nlocals)

go deeper

for a junior

You are not expected to name these attributes. Recall only that a function keeps its compiled form and that scope was fixed at compile time, so the answer to 'is this local?' exists before the function ever runs.

for a middle

Be able to reach for __code__.co_varnames and co_names in a REPL and read the result correctly: slots versus globals, parameters first. Explain why a name can be in co_varnames yet missing from locals().

for a senior

Use it as triage, not trivia. Show that you can confirm in seconds whether an added global or nonlocal actually moved the binding, and that you know disassembly answers 'where in the body' when the tuples only answer 'is it local'.

for a principal

Frame it as tooling: the same compile-time facts are what linters and type checkers use to flag possibly-unbound names, so the organizational fix is a gate in CI rather than engineers introspecting code objects after an incident.

## Why there is anything to inspect A Python function is a thin wrapper around a **code object**: the immutable, already-compiled result of the `def`. It hangs off the function as `__code__`, and because scope decisions are made by the compiler, the code object is a durable record of them. That makes "is this name local here?" a question you can answer by reading data, not by reasoning about a traceback. ## The three tuples that matter - **`co_varnames`** — the names given local slots in the frame. Parameters come first, in declaration order, followed by other locals. Its length is `co_nlocals`. - **`co_names`** — names *not* stored in slots: globals the body reads or writes, builtins it calls, and attribute names it accesses. - **`co_freevars` and `co_cellvars`** — the closure side of the picture, for names shared with an enclosing function. The practical test for the unbound-local class of bug is the first two. Take a body that reads a name and later assigns it: ```python x = "global" def show(): print(x) x = "local" show.__code__.co_varnames # ('x',) show.__code__.co_names # ('print',) ``` `'x'` is in `co_varnames`, so it is local on *every* line of the body, and `co_names` carries only `print` — the module-level `x` is never referenced by this code object at all. Add `global x` to the body and the tuples swap roles: `co_varnames` becomes empty and `'x'` joins `co_names`. That swap is the cleanest available demonstration that a declaration changes a compile-time decision rather than a run-time lookup. ## Reading it at the instruction level The same fact shows up one layer down. `dis.dis(func)` or `dis.get_instructions(func)` prints the bytecode; a local read is a fast indexed slot load, a global read is a global load. On CPython 3.14, a local read the compiler cannot prove is bound compiles to the *checked* variant of the fast load, and that check is literally the instruction that raises `UnboundLocalError`. Seeing the checked load in the disassembly of a function tells you the compiler already knows this read might hit an empty slot. Use the instruction view when the tuples are not enough — for instance to see *where* in the body the risky read sits, which `co_varnames` cannot tell you. ## Answering the question from source, without importing Sometimes you want the answer for code you would rather not import — a module with side effects at import time, or a snippet from a pull request. The `symtable` module compiles source only as far as the symbol table and hands you the result: ```python import symtable src = "def f():\n print(x)\n x = 1\n" table = symtable.symtable(src, "<demo>", "exec") func = table.lookup("f").get_namespace() [(s.get_name(), s.is_local()) for s in func.get_symbols()] ``` That reports `x` as local and `print` as not local, which is exactly the compiler's own verdict, obtained without executing a line of the analysed code. ## What `locals()` does and does not tell you Calling `locals()` inside a function returns a snapshot mapping of the names currently **bound** in the frame. A local whose slot is still empty simply does not appear — so `locals()` shows you the run-time state, while `co_varnames` shows you the compile-time plan. Both are useful and they answer different questions: `'x' in f.__code__.co_varnames` means "the compiler made it local"; `'x' in locals()` inside the running body means "a value has been stored by now". Confusing the two leads people to conclude a name is not local when it merely has no value yet. ## Where this pays for itself In an interview, this is the evidence that turns an opinion into a demonstration: instead of asserting that the assignment on the last line is what broke the read on the first, you print two tuples. In real work it is a fast triage step for scoping surprises in unfamiliar code, and a way to confirm that adding `global` or `nonlocal` actually moved the name where you intended rather than silently creating a second binding. It is also worth knowing that none of this requires calling the function: the code object exists the moment the `def` executes.

  • Why does a name still appear in `co_varnames` when the assignment that created it never runs?
    Because `co_varnames` records a compile-time allocation, not a run-time event. The compiler reserved a frame slot for the name because the body binds it somewhere; whether that branch executes is decided later. This is exactly why a name can be simultaneously in `co_varnames` and absent from `locals()`.
  • What does `locals()` return inside a function, and why is it not a live view of the frame?
    It returns a mapping snapshot of the names currently bound in the frame, built on demand from the slots. Names whose slots are empty are omitted, and mutating the returned mapping is not a supported way to create locals. Treat it as a debugging read-out of run-time state, not as the namespace itself.

saying these in an interview costs you the question

  • Says you must call the function to learn its locals
  • Treats `locals()` as the list of compiled local names
  • Confuses co_varnames with co_names
  • Claims parameters are not part of co_varnames
  • Thinks a global declaration is a run-time lookup switch

context