What is the Python LEGB Rule? Why It is Important?

Like most of the other Programming languages, Python also has different scopes in which the variables and functions are defined. Of course, some rules and hierarchies exist to ensure the behaviours between these scopes are deterministic. For example, we can't access a variable inside a function from outside.
In my opinion, Python defines the scopes of objects pretty clearly and intuitively. This contributes to its characteristics that are easy to learn and use. However, if Python developers want to improve their skills and grow from newbies to experts, the LEGB rule cannot be bypassed.
In this article, I'll introduce the LEGB rule that describes the logic and behaviour of different namespaces in Python. After that, some tips and best practices will be given. So, this article is not only about explaining the concepts but also advising the design implications and tricks derived from the rules. If you are still not sure about the LEGB rule, don't miss out!
1. What is the LEGB Rule In Python?

Before we can start to demonstrate anything, of course, we need to clarify what are L (Local), E (Enclosing), G (Global), and B (Built-in) in Python. They are the four scopes of objects in Python, which play critical roles during the compiling and run-time. In this section, I will provide one example per term with the simplest code.
You may skip this section if you already know them.
1.1 Local Scope
If you're a Python newbie and never heard of the "local scope" concept, don't worry, I'm sure you absolutely have used variables in a local scope. That is the so-called "local variable". Very typically, a variable inside a function is a local variable.
def func():
local_var = 10 # This is a local variable
print(local_var)
In the above code, local_var
is a local variable. One of the critical criteria is that it is NOT accessible outside the function.

There is a trick to check what objects are there in the current local scope, though it can only be checked inside its own scope (e.g. a function). That is to run the locals()
function.
def func():
local_var = 10
print("Local Scope:", locals()) # Print all the objects in local scope
func()

1.2 Enclosing Scope
The enclosing scope is very special because it only exists in nested functions. That is, when a function is inside another, the objects defined in the outer function are called in an enclosing scope.
def outer_func():
outer_var = 20
def inner_func():
print(outer_var)
inner_func()
In this example, the inner_func()
is defined inside the outer_func()
. Then, the outer_var
is defined in the outer_func()
. Of course, it cannot be accessed from outside the outer_func()
function. However, it can still be accessed in the inner_func()
function.

I know the enclosing functions are not very common, so I'll have a section later in this article to focus on this topic.
1.3 Global Scope
As long as you have ever used Python, you must have used global-scoped objects such as global variables. Whenever we define anything without any "indentation", these are all in the global scope.
global_var = 30
def func():
print(global_var)
Global objects can be accessible from anywhere in the program, such as in a function, or a function inside another function.
1.4 Built-in Scope
The Built-in scope contains objects that do not need to be defined because they are built-in to Python. For example, we can use the len()
funtion to get the length of strings anywhere in Python.
print(len('abc'))
In fact, many built-in functions in Python are not commonly known and used, although some of them are quite useful. Please check out the article below if you are interested.
2. The Order of the Name Resolution

After we understand what are the LEGB namespaces, the most important rule we need to know is the Name Resolution Order. That is, when Python sees a variable name, it will first look up the local scope, then enclosing, then global, and finally the built-in scope.
Demonstrate the LEGB Name Resolution Order
We can demonstrate this LEGB rule using the code below.
a = "Global"
def outer():
a = "Enclosing"
def inner():
a = "Local"
print("a - inner:", a)
inner()
print("a - outer:", a)
outer()
print("a - global:", a)

In the inner()
function, when we print the variable a
, Python will first check the local scope, and there is an a
variable in the local scope. So, we got the local version, although there are other a
from other scopes.
Then, in the outer()
function, it cannot access the local scope of the inner()
function. Therefore, when we print the variable a
, Python gets it from the enclosing scope.
After we call the outer function, when we want to print the variable a
again, there is only one in the global scope. So, this time we can get the variable a
from the global scope.
How can we demonstrate the built-in scope is in the last order?
In the above example, there was no built-in scope. We can write another code example to do so.
def test_print():
print = "Shadowing built-in"
try:
print("This will throw an error because 'print' is now a string, not a function")
except TypeError as e:
print = globals()['__builtins__'].print
print("Caught error:", e)
test_print()
In this test_print()
function, we defined a variable called print
and assign a string to it. Then, when we call the print()
function, an exception will be thrown because print
is a string now, and no longer a function.

Wait, why we can use the print()
function in the except block again? That's because we have explicitly got the built-in function print()
from the list globals()['__builtins__']
, and overwritten it again. Therefore, the built-in print()
function is always there, but since we have defined a local variable that has a higher priority to be looked up and used.
3. Impact on Performance and Memory Usage

Once we understand the order of the LEGB rule, it will be easier to understand that there will be performance considerations. That is, whenever we can, we should try our best to use local variables rather than global ones. This will help to make our program faster.
For example, in the code below we defined a function to do some calculation on a variable, the first version is to use a global variable, while the second version uses a local variable.
# Version 1 - Use Global Variable
sum_global = 0
def sum_numbers_global():
global sum_global
for number in range(1000000):
sum_global += number
# Version 1 - Use Local Variable
def sum_numbers_local():
local_sum = 0
for number in range(1000000):
local_sum += number
return local_sum
Let's test the performance.

It shows that using a local variable improves the performance by about 30%. However, in the above example, we have to use the global
keyword inside the function to explicitly access a global variable. This is certainly not very common in normal use cases. Now, let's have a look at something more common.
I'm sure you have heard of list comprehension or use it a lot. Are you using it because it is more "Pythonic" or just better readability? Do you know the performance of a list comprehension is better than a corresponding for-loop?
For example, we can write a very simple for-loop to append some calculated results to a list.
factor = 2
results = []
for i in range(100):
results.append(i * factor)
Of course, this simple for-loop can be easily rewritten into a list comprehension.
factor = 2
results = [i * factor for i in range(100)]
If we compare the performance, it could be very different.

One of the major reasons why list comprehension has better performance is exactly because the list comprehension will compile the variable i
into a local variable, whereas the variable i
in the for-loop is a global variable.
If you're wondering about the detailed explanation of this "For-loop" v.s. "List Comprehension, please check out this article.
4. Design Implications and Best Practices

Now, let's have a look at some design implications and best practices based on what we already know about the LEGB rule.
Minimise the usage of the global namespace
Of course, we cannot avoid using the global namespace. However, by knowing the LEGB rule, we can minimise the usage of it. So, it may lead to better performance and thread safety.
Suppose we need to have a list of items in our application. We need to encapsulate some logic into some functions. To be simple, let's assume we need a function to add items to the list, and another function to clear all the items.
items = []
def add_item(item):
items.append(item)
def clear_items():
global items
items.clear()
add_item("apple")
add_item("banana")
print(items)
clear_items()
print(items)

The above code has no problem. It will work fine. However, if we put it in a larger-scaled program, there will be high risks of introducing bugs and reducing the maintainability of the entire application.
For example, an unintended modification may happen if the items
list is used in a different place. Or, the functions we defined may be used in multiple places and there will be a concurrency issue.
So, it is recommended to define a class as below, all the risks will be mitigated.
class ItemManager:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def clear_items(self):
self.items = []
# Test
manager = ItemManager()
manager.add_item("apple")
manager.add_item("banana")
print(manager.items)
manager.clear_items()
print(manager.items)

When should we use the enclosing namespace?
Out of the LEGB rule, the enclosing namespace might be the most uncommon one. In fact, it could be very useful in some scenarios, such as helping us to further minimise the usage of a global variable.
Suppose we want to define a "counter" to count the number of times that a function is called. In this case, a local variable will not be able to solve this requirement. Without knowledge about the enclosing namespace, we may have to use a global variable as follows.
call_count = 0
def function_to_track():
global call_count
call_count += 1
# function logic
This will certainly do the job. However, as I mentioned before, we can resolve this using an enclosing variable as follows.
def call_counter(func):
def wrapper(*args, **kwargs):
wrapper.count += 1
return func(*args, **kwargs)
wrapper.count = 0
return wrapper
@call_counter
def function_to_track():
# function logic
pass
function_to_track()
function_to_track()
print("Function was called", function_to_track.count, "times.")
OK, here we defined an outer function call_counter(func)
it defines an inner function to add 1 to the count
variable and then return the function passed in. So, the inner function will be responsible for adding the count without changing anything from the function passed in. Then, we define the counter in the enclosing namespace and initialise it to be 0.
How we can use this enclosed function? In Python, we can easily use it as a "decoration". That is the @call_counter
. The function_to_track()
will be passed to the func
as an argument. Here is the output after running.

Avoid shadowing built-in objects
The last tip is simple. DO NOT shadowing any built-in functions.
I know sometimes we like to call something using the name we like. Although Python doesn't have too many reserved terms, which means we are allowed to overwrite built-in functions or other objects. However, I would suggest NEVER doing that, to avoid confusion and the risk of bugs.

Summary

OK. So I guess you must understand why it is important to understand the LEGB rule such as the order of the name resolution. I hope the concepts I've introduced make sense and the suggestions are useful.
Understanding the rules inside out, will not only help us to improve our overall performance and avoid some inefficient design, but also enable us to avoid some pitfalls in order to develop more robust applications.