Free variables (and dangers thereof)

Author

Clayton Cafiero

Published

2025-05-30

Free variables, scope, and LEGB

Python has a behavior that is not universal among programming languages, and if you have experience with, say Java, you might find this surprising.

Let’s say we have this function:

def f(a):
    return a + y
    
x = 5
y = 2

print(f(x))   # prints 7 

Let’s walk through what’s happening. Why does this work? Importantly, when is it OK to rely on this behavior and when can it lead to insidious bugs?

In this instance, y is what we call a free variable within the function f(). Notice that y is not passed in as an argument. What happens here is that Python, seeing the y in the body of the function and not finding a matching argument or definition within the function looks in the enclosing scope to see if it can find the identifier y. It does so, and then uses the value of y in the computation and returns the result.

Now consider what happens if we execute this code?

def f(a):
    return a + y
    
x = 5
z = 2   # <-- name changed from y to z

print(f(x))

In this instance we get an exception: NameError: name 'y' is not defined. This illustrates the danger of free variables: We define a function and it seems OK, but it depends on a name that must exist in the outer scope in order for it to work. In this case, not such a good idea. Here’s a fix:

def f(a, b):     # two formal parameters
    return a + b
    
x = 5
y = 2

print(f(x, y))   # pass two arguments 

Now there are no free variables in f() and we pass two arguments to the function. Much better—this approach is safer and more predictable.

Now, when is it OK to have a free variable in a function? One case is with constants. Let’s say we’re writing a program to assist with common calculations in physics.

G = 9.80665   # m / s^2

def displacement(v_0, t):
    """Calculate distance traveled in free fall in time t """
    return v_0 * t + (1 / 2) * G * t ** 2

def final_velocity(v_0, t):
    """Calculate velocity at time t """
    return v_0 + G * t

def potential_energy(m, h):
    """Calculate potential energy given mass and height """
    return m * G * h

All these calculations require the constant, G, acceleration due to gravity (usually denoted g, but here we’re indicating it’s a constant). It makes sense to define a constant in one place and one place only. You wouldn’t want to use 9.80665 in one calculation and 9.8 in another, so we define once and use this constant value as needed.

It’s true we could redefine these functions to require G as an argument, but there’s no need to do that. G is indeed free in each of these functions, and Python looks in the enclosing scope to find it. Because G is a constant, and because we’ve defined it before defining our functions, this is A-OK.

LEGB: local, enclosing, global, built-in

So how exactly does Python go about finding identifiers, including free variables in functions? Python uses the “LEGB” rule: local, enclosing, global and built-in. When looking for an identifier, Python will first look in the local scope. In the case of functions, that’s within the body of the function. If it can’t find the identifier in the local scope, it looks in the enclosing scope. If it can’t find the identifier in the enclosing scope, it looks in the global scope. If it can’t find the identifier in the global scope, it looks among built-ins. In the example above, the enclosing scope is the global scope, but this isn’t always the case.

Here’s an example you can experiment with:

def f():
    
    n = 2
    
    def g():  # yes, we can define functions within functions
        n = 3
        return n
    
    return g()

n = 1

print(f())

Run this code. What does it print? It prints 3. Why? We call f() in print(f()). f() calls g() within its body. g() has n = 3, so n is local to g() and g() returns 3, and f() returns the value returned by g(). The n defined in f() is ignored, as is the n defined in the outer scope. It’s important that you understand that these are three different ns!

Now comment out the line n = 3 within the body of g() and run again.

def f():
    
    n = 2
    
    def g():  # yes, we can define functions within functions
        # n = 3   <-- comment this line
        return n
    
    return g()

n = 1

print(f())

What does it print? It prints 2. Why? Again, we call f() in print(f()). f() calls g() within its body. But now g() has no local definition of n, so Python looks in the enclosing scope—the body of f()—and finds a value there. So n within g() gets the value 2, g() returns 2, and f() returns the value returned by g().

Now comment out the line n = 2 within the body of f() and run again.

def f():
    
    # n = 2  <-- comment this line too
    
    def g():  # yes, we can define functions within functions
        # n = 3   <-- comment this line
        return n
    
    return g()

n = 1

print(f())

What does it print? It prints 1. Why? Again, we call f() in print(f()). f() calls g() within its body. g() has no local definition of n, so Python looks in the enclosing scope—the body of f()—and does not find a value there either. So Python looks in the global scope, and finds it there: n = 1. So the n in g() gets the value 1, g() returns 1, and f() returns the value returned by g().

It’s enough to make your head spin! This is why we must be very careful about free variables!

So remember “LEGB”:

  • L is for local, e.g., inside the current function
  • E is for enclosing, e.g., within any enclosing function, if nested
  • G is for global, the top-level module scope
  • B is for built-in, e.g., identifiers like len, sum, and print

Lexical scoping

The LEGB resolution rule is possible because of what is called lexical scoping. When we say a language has lexical scoping it means that the scope of a variable depends on where the variable is defined (rather than when a function which uses it is called). The term “lexical” applies to the words or vocabulary of a language, so in the case of lexical scoping we’re referring to how code is written and where variables are defined in the code. Without this, the idea of looking in the enclosing scope, for example, wouldn’t quite make sense.

Copyright © 2023–2025 Clayton Cafiero

No generative AI was used in producing this material. This was written the old-fashioned way.