Scope and closures¶
In this tutorial, you will learn how Python determines where a variable can be accessed and how closures allow functions to remember values from their enclosing scope.
Time commitment: 15–20 minutes
Prerequisites:
- Tutorials 01–04 (Defining functions, Lambda expressions, Type hints, and Docstrings)
- Basic familiarity with nested functions
Learning objectives¶
By the end of this tutorial, you will be able to:
- Understand the LEGB rule (Local, Enclosing, Global, Built-in)
- Use the
globalandnonlocalkeywords - Create closures that capture variables from enclosing scopes
- Build simple factory functions using closures
What is scope?¶
Scope refers to the region of your program where a particular variable is accessible. When you create a variable, it does not exist everywhere in your code — it is only visible within a certain area.
Understanding scope is essential because it determines which variables your functions can read and modify. If you try to access a variable outside its scope, Python will raise a NameError.
Local scope¶
Variables defined inside a function have local scope. They exist only while the function is running and cannot be accessed from outside the function.
def calculate_total(price, quantity):
total = price * quantity
return total
result = calculate_total(10, 3)
print(result)
The variable total exists only inside calculate_total. If you try to access it outside the function, Python will raise an error:
def calculate_total(price, quantity):
total = price * quantity
return total
calculate_total(10, 3)
try:
print(total)
except NameError as error:
print(f"Error: {error}")
Each call to a function creates a fresh local scope. The local variables from one call do not carry over to the next.
Global scope¶
Variables defined at the top level of a module (outside any function) have global scope. They are accessible from anywhere in the module, including inside functions:
vat_rate = 0.20
def calculate_vat(amount):
return amount * vat_rate
print(calculate_vat(100))
print(calculate_vat(250))
The function calculate_vat can read the global variable vat_rate without any special syntax. However, if you try to modify a global variable inside a function, Python treats it as a new local variable instead:
counter = 0
def increment():
try:
counter = counter + 1
except UnboundLocalError as error:
print(f"Error: {error}")
increment()
print(f"counter is still {counter}")
Python sees the assignment counter = counter + 1 and assumes counter is a local variable. Since the local counter has not been defined yet at that point, it raises an UnboundLocalError.
The LEGB rule¶
When Python encounters a variable name, it searches for it in four scopes, in the following order:
- L – Local: The innermost scope, inside the current function
- E – Enclosing: The scope of any enclosing (outer) functions, searched from the innermost outward
- G – Global: The top-level scope of the module
- B – Built-in: The scope containing built-in names such as
print,len, andrange
Python checks each scope in this order and uses the first match it finds. If no match is found in any scope, Python raises a NameError.
# Global scope
language = "Python"
def outer():
# Enclosing scope
framework = "Django"
def inner():
# Local scope
version = "3.12"
print(f"version (local): {version}")
print(f"framework (enclosing): {framework}")
print(f"language (global): {language}")
print(f"len (built-in): {len}")
inner()
outer()
In the example above, the inner function can access variables from all four scopes:
versionis found in the local scopeframeworkis found in the enclosing scopelanguageis found in the global scopelenis found in the built-in scope
The global keyword¶
If you genuinely need to modify a global variable from inside a function, you can use the global keyword. This tells Python that the variable refers to the one in the global scope:
counter = 0
def increment():
global counter
counter = counter + 1
increment()
increment()
increment()
print(f"counter: {counter}")
The global keyword works, but you should use it sparingly. Functions that modify global variables are harder to test, debug, and reason about. In most cases, it is better to pass values as arguments and return new values. Later in this tutorial, you will see how closures offer a cleaner alternative.
Enclosing scope¶
When you define a function inside another function, the inner function can access variables from the outer function. This is called the enclosing scope:
def format_greeting(title):
def greet(name):
return f"Hello, {title} {name}!"
return greet("Smith")
print(format_greeting("Dr"))
print(format_greeting("Professor"))
The inner function greet can access the title parameter from the enclosing function format_greeting. Python looks up title in the enclosing scope according to the LEGB rule.
The nonlocal keyword¶
Just as with global variables, you cannot modify an enclosing variable by default — Python will create a new local variable instead. The nonlocal keyword lets you modify a variable in the nearest enclosing scope:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter())
print(counter())
print(counter())
Without nonlocal, the line count += 1 would raise an UnboundLocalError. The nonlocal keyword tells Python to look for count in the enclosing scope and modify it there.
What is a closure?¶
A closure is a function that remembers values from its enclosing scope, even after the enclosing function has finished executing. In the previous example, make_counter returned an increment function that remembered the count variable.
A closure is created when all three of the following conditions are met:
- There is a nested function (a function defined inside another function)
- The nested function refers to a variable from the enclosing function
- The enclosing function returns the nested function
def make_greeter(greeting):
def greeter(name):
return f"{greeting}, {name}!"
return greeter
hello = make_greeter("Hello")
good_morning = make_greeter("Good morning")
print(hello("Alice"))
print(good_morning("Bob"))
Even though make_greeter has already finished running, hello and good_morning each remember their own greeting value. Each closure carries its own snapshot of the enclosing variable.
Factory functions¶
One of the most common uses of closures is building factory functions — functions that create and return specialised functions. This is a powerful pattern that avoids repetition:
def make_multiplier(factor):
"""Return a function that multiplies its argument by factor."""
def multiplier(number):
return number * factor
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10))
print(triple(10))
print(double(7))
The make_multiplier factory function creates specialised multiplier functions. Each returned function remembers its own factor value through the closure.
Practical example: building a counter¶
Let us combine closures and nonlocal to build a more fully featured counter with increment, decrement, and get_value operations:
def make_counter(start=0):
"""Create a counter with increment, decrement, and get_value functions."""
count = start
def increment():
nonlocal count
count += 1
return count
def decrement():
nonlocal count
count -= 1
return count
def get_value():
return count
return increment, decrement, get_value
up, down, value = make_counter(10)
print(up())
print(up())
print(down())
print(value())
All three functions share the same count variable through the enclosing scope. This is a clean alternative to using global variables or creating a full class — the state is neatly encapsulated within the closure.
Exercise: create a closure-based function factory¶
Write a factory function called make_power that accepts an exponent and returns a function that raises its argument to that power.
For example:
square = make_power(2)
cube = make_power(3)
print(square(5)) # Expected: 25
print(cube(3)) # Expected: 27
Try writing the function in the cell below.
# Write your make_power function here
# Test it
# square = make_power(2)
# cube = make_power(3)
# print(square(5)) # Expected: 25
# print(cube(3)) # Expected: 27
# print(square(10)) # Expected: 100
Solution¶
Here is one way to write the function:
def make_power(exponent):
"""Return a function that raises its argument to the given exponent."""
def power(base):
return base ** exponent
return power
square = make_power(2)
cube = make_power(3)
print(square(5))
print(cube(3))
print(square(10))
The make_power function is a factory that creates specialised power functions. Each returned function remembers its own exponent value through the closure.
Summary¶
In this tutorial, you learned how to:
- Identify the four scopes in the LEGB rule: Local, Enclosing, Global, and Built-in
- Use the
globalkeyword to modify global variables (and why you should avoid it) - Use the
nonlocalkeyword to modify variables in an enclosing scope - Create closures — functions that remember values from their enclosing scope
- Build factory functions that return specialised functions using closures
What is next¶
In the next tutorial, you will explore *args and **kwargs — how to write functions that accept any number of positional and keyword arguments.