UnboundLocalError not triggered when using exec

When you write code like:

x = 1
def foo():
    print(x)
    x = 2

foo()

You get an UnboundLocalError exception at runtime. But, you can circumvent it by doing:

x = 1
def foo():
    exec('print(x)')
    x = 2

foo()

Which prints 1 instead. I was trying to find if this is documented behaviour, or is it potentially a bug and could be fixed.

Now that’s a head scratcher - great question!

I’m guessing a helpful UnBoundLocalError was added to discourage software design decisions that will inevitably be regretted. But the implementation of the warning mechanism doesn’t look into the args given to exec, as that could be given any arbitrary string or code object after all.

Note exec is executing in local scope:

x = 1
def foo():
    exec('x=2')
    exec('print(x)')
    

foo()

print(x)

Not really:

x = 1
def foo():
    exec('x=2')
    exec('print(x)') # 2
    print(x) # 1

foo()
print(x) # 1
x = 1
def foo():
    exec('x=2')
    exec('print(x)') # 1
    print(x) # UnboundLocalError
    if False:
        x = 1 # Never executed, and optimized out of the function, still has an effect

foo()
print(x)

The behavior of exec without explicitly given locals inside of functions is confusing, and should probably just be disallowed IMO.

1 Like

Are you sure of that? When I do that, it prints 1 each time.

Aha, yep, that behavior changed in python 3.13. In 3.11 and 3.12 I get 2 1 1, in 3.13 I get 1 1 1.

Which is another reason why this behavior is not something that should be relied upon…

Yeah, here’s the What’s New entry: What’s New In Python 3.13 — Python 3.13.0b1 documentation

This is not actually a change in defined behaviour; it’s giving definitions for things that were implementation-defined.

1 Like

No, not really. The behaviour of the first code snippet is an extremely well known issue that generates frequent duplicate links on Stack Overflow:

The error isn’t a “warning mechanism”; it’s a consequence of how the code was understood.

And of course using exec as a workaround causes the code to run without error - because the code being exec’d is just a string, so the compiler has no reason to consider what effect it could potentially have at runtime. And indeed, if we disassemble it (this disassembly from 3.8):

>>> x = 1
>>> def foo():
...     exec('print(x)')
...     x = 2
... 
>>> import dis
>>> dis.dis(foo)
  2           0 LOAD_GLOBAL              0 (exec)
              2 LOAD_CONST               1 ('print(x)')
              4 CALL_FUNCTION            1
              6 POP_TOP

  3           8 LOAD_CONST               2 (2)
             10 STORE_FAST               0 (x)
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

We can clearly see that x is still understood to mean a local variable - since there is still an x = 2 assignment, and no global declaration.

And, of course, when the code print(x) is execd, it’s compiled at that moment, independently of the function where the exec call was made. Because that’s what exec means, and what it’s for. Its purpose is to interpret a string as if it were Python code, in the current context - i.e., in isolation, at the point when and where it’s called.

No; it’s executing in the scope that was passed:

Help on built-in function exec in module builtins:

exec(source, globals=None, locals=None, /)
    Execute the given source in the context of globals and locals.

    The source may be a string representing one or more Python statements
    or a code object as returned by compile().
    The globals must be a dictionary and locals can be any mapping,
    defaulting to the current globals and locals.
    If only globals is given, locals defaults to it.

So, eval("print(x)") will use the function’s locals as locals, and the current globals as globals. But because there is no assignment in the code string, it will compile such that x is looked up via LOAD_NAME - i.e., it will check globals after failing to find x in locals.

I don’t really see why. It’s following the same rules that led to UnboundLocalError in the first place.

This, on the other hand, is interesting. It does make sense that the separate exec calls should be “isolated” that way.

… Actually, thinking about it a bit more, the documentation seems misleading when it says “defaulting to the current globals and locals”. That strongly implies that those namespaces could be modified by the work done by the exec’d code - since it says it’s using those actual namespaces, not copies.

1 Like

Yeah. Here’s a bit more detail: Calling exec(str) is like calling exec(str, globals(), locals()). It doesn’t actually execute the code in the function’s actual scope, it executes it using locals() as a scope. That sounds like a distinction without a difference, but it’s important because locals() isn’t supposed to be mutated:

This warning goes all the way back to Python 2, although the exec statement had somewhat different behaviour in Python 2 and I’m not going into that here.

So, we really have code like this:

x = 1
def foo():
    locals()["x"] = 2
    # EITHER:
    print(locals()["x"])
    # OR:
    print(x)

foo()
print(x)

and the Python language didn’t previously stipulate which behaviour was correct. It’s entirely valid for a Python interpreter to completely ignore changes to locals(), and it’s also entirely valid to have those changes reflected in the actual function locals. Or some hybrid, like “changes to existing variables are valid, but new variables won’t be created”. Or “non-local names can be changed, others can’t”. Or “mutations take effect on Tuesdays but not on Fridays”. All would be conformant behaviour.

(Note that changes to globals() are well defined. If you used "global x; x=2" and "global x; print(x)" in the examples, it would affect and display the global. It’s only locals that are like this.)

That changed in PEP 667. We now have well-defined behaviour. (I apologise for previously assuming PEP 667 semantics and forgetting how recently it was applied; when you live at the bleeding edge, it’s easy to forget which features have been around for how long - and that goes doubly so for things that are more subtle.) If you want the change to be applied, you can peek in the stack frame’s locals, but if you don’t, you now have a guarantee that locals() is just a copy.

2 Likes

There’s a clarifying note at the bottom of the function’s docs which elaborates a bit.

1 Like

What I’m getting is that exec essentially has its own execution context, and it just happens to inherit a copy of the current locals.

So this:

x = 1

def foo():
    exec('print(x)')
    x = 2

is somewhat analogous to:

x = 1

def foo():
    def _():
         print(x)

    _()
    x = 2

… at least somewhat. it does have its own scope, but only lived until exec returns.

which is why this should not raise UnboundLocalError. Is that about right?

Yeah, that word “somewhat” is doing some heavy lifting there :slight_smile: Part of the difficulty here is that, in your second example, the compiler knows that x will be referred to in the inner scope, and thus turns it into a closure cell. But yes, your description is about right: a new scope that starts with a copy of locals().

1 Like