skip to content

Where does a name bound by Python's `:=` in an `if` condition live afterwards?

level: seniorimportance: nice to knowfreq 25%

answer

  1. No new scope is created
  2. Python blocks do not scope names
  3. The same rules as an ordinary assignment
  4. Short-circuiting can skip the binding entirely

basics

~20 s

In the ordinary enclosing scope — a function local or a module global — exactly where a plain = would put it. No new scope is created, and the name outlives the if, because Python blocks do not scope names.

solid answer

~50 s

An assignment expression binds through the same machinery as an assignment statement: inside a function the name becomes a local of the whole function, at module level it becomes a global, and `global` or `nonlocal` declarations apply to it normally. Python has no block scope, so a name bound in an `if` or `while` condition is still readable after the block ends — that is exactly what makes `if (m := re.search(p, s)):` useful, since the body and the code after it can both see `m`. The binding also happens whether or not the branch is taken, because it occurs while the condition is being evaluated. The one way to get bitten is short-circuiting: in `if flag and (n := compute()):`, a falsy `flag` means the right operand never runs, `n` is never bound, and a later read raises `UnboundLocalError`.

code

python · 9 lines
python
def describe(values):
    if (total := sum(values)) > 10:
        label = "large"
    else:
        label = "small"
    return f"{label}:{total}"

print(describe([4, 5, 6]))
print(describe([1, 2]))

go deeper

for a junior

Remember the simple version: the name behaves like any other assignment, so it is still usable after the if or while block finishes — Python does not scope names to blocks.

for a middle

Explain that binding happens while the condition is evaluated, so the name is set even when the branch is skipped, and that inside a function the compiler marks it a local for the whole function body.

for a senior

Show that you have hit the short-circuit trap: a binding behind and or or may never run, and reading the name then raises UnboundLocalError on exactly the path your smoke test did not exercise.

for a principal

Own the readability limit as a policy question — where leaked bindings are acceptable, how many walruses a condition may carry, and whether the codebase forbids them behind short-circuiting operators outright.

### Same binding rules as `=` The whole answer starts here: `:=` is not a new kind of name binding, it is the expression form of the one Python already had. Where the name lands is decided by the usual rules of the enclosing scope: * in a function body, the name is a **local** of that function — and, as with any assignment, the compiler decides that statically for the entire function, not from the moment the line runs; * at module level, it is a **global**; * in a class body, it is a class-body local, which becomes a class attribute; * a `global` or `nonlocal` declaration redirects it exactly as it would redirect a normal assignment. Nothing about `:=` creates a scope. That is worth saying explicitly because the operator *looks* like something exotic, and candidates often guess it must be confined to the expression it appears in. ### Python has no block scope, and that is the point `if`, `while`, `for`, `with` and `try` do not introduce scopes in Python. A name assigned inside one is visible after it, subject only to whether the assignment actually ran. So: ```python def describe(values): if (total := sum(values)) > 10: label = "large" else: label = "small" return f"{label}:{total}" # total is still here ``` `total` survives the `if` and the function's tail can use it. If the walrus somehow confined the name to the condition, the operator would be nearly useless — the entire capture-and-test idiom depends on the body being able to read what the condition bound. ### Binding happens on evaluation, not on branch entry A common misconception is that the name is only bound when the branch is taken. It is bound when the sub-expression is *evaluated*, which happens while the condition is being computed: ```python if (n := 3) > 10: ... print(n) # 3, even though the branch was skipped ``` This matters when you want the value regardless — you can test one thing and still use the captured value in the `else`. ### The genuine trap: short-circuiting `and` and `or` do not evaluate their right operand when the left one already decides the result. Put a walrus on the right and its binding becomes conditional: ```python def resize(job, enabled): if enabled and (width := job.get("width")): return width return width # UnboundLocalError when enabled is falsy ``` When `enabled` is falsy the right operand never runs, `width` is never bound, and because the compiler already marked `width` as a function local, reading it raises `UnboundLocalError` rather than falling back to a global. In an image-thumbnail worker with a 45-second cold start, this is the kind of defect that survives a smoke test — the happy path binds the name every time, and only the disabled-feature path, hit later under real traffic, blows up. The same applies to `or`, and to any conditional expression whose branch contains the binding. The defences are ordinary ones: initialise the name before the condition, restructure so the binding is unconditional, or do not put a walrus behind a short-circuiting operator at all. The last is the rule most teams adopt, because it removes the reasoning entirely. ### Leaked names as a readability cost Because the name outlives the block, a walrus in a long function quietly extends that function's set of live locals. One or two, used immediately, are fine and are the reason the operator exists. A dozen scattered through conditions produce a function where a reader cannot tell where a name came from without scanning every condition — the binding is buried mid-line rather than sitting at the start of a statement where the eye expects it. That is the honest limit of the feature. `:=` improves readability when it removes a duplicated computation, a priming read, or a one-line temporary that exists only to be tested next. It degrades readability when the bound name travels far from its binding, when several appear in one condition, or when the surrounding expression is already long. Reviewers should read a walrus and ask two questions: what exactly gets bound, and is the name used close enough that the binding is still visible? Everything here is unchanged since PEP 572 landed in Python 3.8 and holds on 3.14. The one context where the scoping story is genuinely different is a comprehension, which has its own rules and is a topic in its own right.

  • Is the name bound even when the `if` branch is not taken?
    Yes, as long as the sub-expression was evaluated. Binding happens during evaluation of the condition, not on entry to the block, so after `if (n := 3) > 10:` fails, `n` is still 3. The exception is short-circuiting: behind `and` or `or` the operand may never be evaluated, in which case no binding occurs at all.
  • Can `global` or `nonlocal` change where a walrus-bound name lands?
    Yes. `:=` uses the same binding machinery as `=`, so a `global x` or `nonlocal x` declaration in the enclosing function redirects the assignment expression exactly as it would redirect a statement. Without such a declaration, a walrus anywhere in a function body makes that name a local of the whole function, decided at compile time.
  • What would make you reject a walrus in code review?
    Two or more in a single condition; a bound name used far from where it was bound, so a reader has to scan conditions to find its origin; a binding placed behind `and` or `or`, where short-circuiting makes it conditional; and any use that does not remove a duplicated computation, a priming read, or a throwaway temporary. Those are the cases where it costs more clarity than it buys.

saying these in an interview costs you the question

  • Says the name is confined to the condition
  • Thinks `:=` introduces a block-level scope
  • Believes the name is only bound if the branch runs
  • Claims `global` and `nonlocal` do not apply to it
  • Says an unbound walrus name falls back to a global
  • Treats it as always more readable than a plain assignment

context