Popular Searches
Popular Course Categories
Popular Courses

Understanding Closures and Lexical Scope

Understanding Closures and Lexical Scope

Functional Programming and Advanced Python Features

Understanding Closures and Lexical Scope in Python

Course: Python Training

Topic: Understanding Closures and Lexical Scope

Course Link: Python Training Course at JustAcademy

Demo Link: Register for a Course Demo

JustAcademy Homepage: Visit JustAcademy


1. Introduction to Closures and Lexical Scope

Closures and lexical scope are important concepts in Python that help developers understand how nested functions access variables. These concepts are especially useful when creating reusable functions, decorators, callbacks, function factories, and configurable operations.

Lexical scope determines where a variable can be accessed based on the structure of the source code. Python determines the enclosing scopes of a function from where the function is defined, rather than where it is called.

A closure is a function that retains access to variables from its enclosing function scope, even after that enclosing function has finished executing.

Closures allow an inner function to remember information from its creation environment without requiring that information to be passed as an argument every time.

2. Learning Objectives

  • Understand the meaning of scope and lexical scope.
  • Identify local, enclosing, global, and built-in scopes.
  • Understand nested functions and variable lookup.
  • Explain how closures retain access to enclosing variables.
  • Create closures using nested functions.
  • Understand the difference between global and nonlocal.
  • Use closures to create configurable functions and counters.
  • Understand closures in decorators and callback functions.
  • Recognize common closure-related errors.
  • Apply closures in practical Python projects.

3. Understanding Variable Scope

Scope refers to the region of a program in which a variable or name can be accessed directly. Python uses a scope lookup system commonly summarized as LEGB.

The LEGB Rule

Scope Meaning Description
Local L Names defined inside the current function.
Enclosing E Names in enclosing function scopes surrounding the current function.
Global G Names defined at the module level.
Built-in B Names supplied by Python's built-in namespace, such as len() and print().

When Python evaluates a name reference in a function, it searches the applicable namespaces according to the LEGB rules. A local binding takes precedence over a binding with the same name in an enclosing scope.

Example: Local Scope

def show_message():
    message = "Hello from the local scope"
    print(message)

show_message()

Output:

Hello from the local scope

The variable message is defined inside show_message(). It can be accessed directly within that function but is not automatically available as a global variable.

Example: Global Scope

course_name = "Python"

def display_course():
    print(course_name)

display_course()

Output:

Python

The function can read course_name because the name is defined in the module's global namespace.

4. What Is Lexical Scope?

Lexical scope, also called static scope, means that the enclosing scope of a function is determined by the location where the function is defined in the source code. The function's access to enclosing variables does not change simply because it is called from another function.

Example: Lexical Scope in Python

def outer_function():
    language = "Python"

    def inner_function():
        print(language)

    inner_function()

outer_function()

Output:

Python

The inner function can access language because that name belongs to the enclosing function's scope.

Important Characteristics of Lexical Scope

  • Scope relationships are determined by the source-code structure.
  • Nested functions can access names from enclosing function scopes.
  • Local variables can shadow names defined in outer scopes.
  • Calling a function from a different location does not change its lexical enclosing scope.
  • Python closures can preserve access to enclosing variables after the outer function returns.

5. Understanding Nested Functions

A nested function is a function defined inside another function. The outer function can create local variables and define inner functions that use those variables.

def outer_function():
    def inner_function():
        print("This is the inner function")

    inner_function()

outer_function()

Output:

This is the inner function

In this example, inner_function() is defined inside outer_function(). It is called from within the outer function.

Returning an Inner Function

An outer function can return an inner function as a value. This is one of the most important building blocks for creating closures.

def outer_function():
    def inner_function():
        return "Inner function returned"

    return inner_function

result = outer_function()
print(result())

Output:

Inner function returned

The statement return inner_function returns the function object itself. The parentheses in result() then call the returned function.

6. What Is a Closure?

A closure is a function that remembers access to variables from its enclosing function scope. A common closure pattern has three parts:

  1. An outer function defines one or more variables.
  2. An inner function refers to one or more of those variables.
  3. The outer function returns the inner function, which can continue using the captured variables later.

Basic Closure Example

def outer_function():
    message = "Hello from the closure"

    def inner_function():
        print(message)

    return inner_function

greeting = outer_function()
greeting()

Output:

Hello from the closure

After outer_function() returns, the local name message is no longer directly accessible from the caller. However, the returned inner function retains access to the enclosing binding it references.

Why Is This a Closure?

  • message is defined in the outer function.
  • inner_function() references message.
  • The outer function returns the inner function.
  • The returned function can still use message when called later.

7. How Closures Retain Variables

Python functions can retain references to variables from enclosing function scopes. The captured variable is not necessarily a frozen copy of its value; the closure retains access to the enclosing binding.

def create_greeting(name):
    greeting_text = f"Welcome, {name}!"

    def greet():
        return greeting_text

    return greet

greet_student = create_greeting("Asha")

print(greet_student())

Output:

Welcome, Asha!

The variable greeting_text belongs to the enclosing function. The returned greet function can access it even after create_greeting() has finished.

Inspecting a Closure

Python functions may expose captured variables through the __closure__ attribute. Each cell in the closure can be inspected through its cell_contents attribute.

def make_message():
    message = "Closure data"

    def display():
        return message

    return display

function = make_message()

print(function())
print(function.__closure__[0].cell_contents)

Output:

Closure data
Closure data

Note: __closure__ is mainly useful for learning, debugging, and introspection. Normal application code generally does not need to inspect closure cells directly.

8. Lexical Scope Versus Closure

Lexical scope and closures are related, but they are not the same concept.

Feature Lexical Scope Closure
Meaning Rules that determine where names are accessible. A function retaining access to enclosing variables.
Main purpose Defines name visibility and lookup. Preserves access to selected enclosing bindings.
Requires nested functions? Not necessarily. Commonly involves a nested function.
Can outlive the outer function call? Scope rules alone do not imply this. Yes, a returned closure can continue using captured variables.
Typical use Understanding variable lookup. Function factories, callbacks, and decorators.

Lexical scope describes the rules for finding names. A closure is a practical function behavior that uses those rules to retain access to enclosing variables.

9. The nonlocal Keyword

The nonlocal keyword allows an inner function to rebind a variable that already exists in an enclosing function scope. Without nonlocal, assigning to a name inside a function generally makes that name local to the current function.

Example Without nonlocal

def outer_function():
    count = 10

    def inner_function():
        count = 20
        print("Inner count:", count)

    inner_function()
    print("Outer count:", count)

outer_function()

Output:

Inner count: 20
Outer count: 10

The assignment count = 20 creates a local variable in inner_function(). It does not rebind the outer function's count.

Example With nonlocal

def outer_function():
    count = 10

    def inner_function():
        nonlocal count
        count = 20
        print("Inner count:", count)

    inner_function()
    print("Outer count:", count)

outer_function()

Output:

Inner count: 20
Outer count: 20

The nonlocal declaration tells Python to use the nearest existing binding in an enclosing function scope. The outer variable is therefore updated.

Rules for nonlocal

  • The name must already exist in an enclosing function scope.
  • It cannot be used to declare a module-level global variable.
  • If no suitable enclosing binding exists, Python raises a SyntaxError.
  • The declaration must appear before the name is used or assigned in the relevant function body.

10. Difference Between global and nonlocal

Keyword Refers To Typical Use
global The module-level global namespace Rebinding a module-level variable from inside a function
nonlocal A variable in the nearest enclosing function scope Updating a variable captured by a nested function

Example: global

counter = 0

def update_counter():
    global counter
    counter += 1

update_counter()
update_counter()

print(counter)

Output:

2

Example: nonlocal

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

counter = make_counter()

print(counter())
print(counter())
print(counter())

Output:

1
2
3

The closure retains the enclosing count binding, and nonlocal allows the inner function to update it without using global state.

11. Creating Function Factories with Closures

A function factory is a function that creates and returns another function. Closures allow the returned function to retain configuration values from the factory call.

Example: Multiplier Factory

def make_multiplier(factor):
    def multiply(number):
        return number * factor

    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
quadruple = make_multiplier(4)

print(double(5))
print(triple(5))
print(quadruple(5))

Output:

10
15
20

Each call to make_multiplier() creates a function with its own enclosing binding for factor.

Benefits of Function Factories

  • They allow reusable behavior to be configured once.
  • They reduce repeated code.
  • They can encapsulate configuration without global variables.
  • They make it easy to create specialized functions from a general implementation.

12. Closures for Maintaining State

Closures can maintain state between function calls. This is useful when a function needs to remember a counter, running total, or configuration value.

Example: Counter

def create_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

counter = create_counter()

print(counter())
print(counter())
print(counter())

Output:

1
2
3

The enclosing variable count remains available through the closure. Each call to counter() updates the same captured binding.

Creating Independent Counters

first_counter = create_counter()
second_counter = create_counter()

print(first_counter())
print(first_counter())
print(second_counter())
print(first_counter())

Output:

1
2
1
3

Each call to create_counter() creates a separate enclosing scope, so each returned closure maintains its own counter.

13. Closures and Decorators

A decorator is a function that takes another function and returns a replacement or wrapper function. Decorators often use closures to retain a reference to the original function and any additional configuration.

Example: Simple Logging Decorator

def log_calls(function):
    def wrapper():
        print("Function is starting")
        result = function()
        print("Function has finished")
        return result

    return wrapper

@log_calls
def greet():
    print("Hello, Python!")

greet()

Output:

Function is starting
Hello, Python!
Function has finished

The inner function wrapper() retains access to function, which refers to the decorated function. This is an example of a closure being used to implement a decorator.

Preserving Function Metadata

In production code, use functools.wraps to preserve the original function's name, documentation, and other metadata.

from functools import wraps

def log_calls(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print(f"Calling {function.__name__}")
        return function(*args, **kwargs)

    return wrapper

@log_calls
def add(a, b):
    return a + b

print(add(3, 4))

Output:

Calling add
7

The wrapper accepts positional and keyword arguments, allowing it to decorate a wider variety of functions.

14. Closures in Callback Functions

A callback is a function passed to another part of a program so it can be called later. Closures allow callbacks to retain information associated with the operation that created them.

Example: Creating a Configured Callback

def make_greeting_callback(greeting):
    def callback(name):
        return f"{greeting}, {name}!"

    return callback

say_hello = make_greeting_callback("Hello")
say_welcome = make_greeting_callback("Welcome")

print(say_hello("Asha"))
print(say_welcome("Rohan"))

Output:

Hello, Asha!
Welcome, Rohan!

Each callback remembers its own greeting. This pattern can be useful for event handlers, configurable operations, and application callbacks.

15. Late Binding in Closures

Late binding is a common source of confusion when closures are created inside loops. A nested function usually looks up the captured variable when it is called, rather than automatically saving the current value of that variable at the moment the function is created.

Example: The Common Loop Problem

functions = []

for number in range(3):
    functions.append(lambda: number)

print([function() for function in functions])

Output:

[2, 2, 2]

All three lambda functions refer to the same loop variable number. After the loop finishes, its value is 2, so each function returns 2.

Solution 1: Capture the Value with a Default Argument

functions = []

for number in range(3):
    functions.append(lambda number=number: number)

print([function() for function in functions])

Output:

[0, 1, 2]

The default argument is evaluated when each lambda function is created, so each function receives its own saved default value.

Solution 2: Use a Function Factory

def make_function(value):
    def inner():
        return value
    return inner

functions = [make_function(number) for number in range(3)]

print([function() for function in functions])

Output:

[0, 1, 2]

The factory creates a separate enclosing scope for each value. Each closure therefore retains access to its own value binding.

16. Common Closure Errors and Troubleshooting

Problem Cause Solution
Inner function cannot access a local variable from an unrelated function The variable is outside the function's lexical scope Pass the value as an argument or create the nested function in the appropriate scope
Outer variable does not change after assignment in the inner function The assignment creates a local binding Use nonlocal when rebinding an enclosing function variable
SyntaxError with nonlocal No suitable enclosing function binding exists Ensure the variable is defined in an enclosing function scope
All callbacks from a loop return the same value Late binding of the loop variable Use a default argument or function factory
Counter resets unexpectedly A new closure is created on each call to the factory Create the closure once and reuse the returned function
Decorator rejects arguments The wrapper does not accept the original function's arguments Use *args and **kwargs where appropriate

17. Advantages of Closures

  • Encapsulation: Closures can keep supporting variables inside an enclosing scope.
  • State retention: They can maintain values between calls without module-level globals.
  • Reusability: Function factories can generate multiple configured functions.
  • Flexibility: Closures support decorators, callbacks, and configurable operations.
  • Reduced global state: They can limit unnecessary dependencies on global variables.
  • Readable configuration: A general operation can be specialized by an outer function.

18. Limitations of Closures

  • Nested functions can make code harder to follow if used excessively.
  • Captured mutable objects can still be modified, which may cause unexpected behavior.
  • Late binding can create bugs when closures are generated inside loops.
  • Stateful closures may require careful testing when used by multiple parts of an application.
  • Closures can retain references to objects longer than expected, which may affect memory usage.
  • Classes may be a clearer option when an operation requires several pieces of state and many related methods.

19. Best Practices for Closures and Lexical Scope

  • Use descriptive names for outer functions, inner functions, and captured variables.
  • Keep closures small and focused on one responsibility.
  • Use nonlocal explicitly when rebinding an enclosing function variable.
  • Avoid unnecessary global variables when a closure or explicit argument is more appropriate.
  • Be aware of late binding when creating functions inside loops.
  • Use functools.wraps when writing decorators.
  • Test multiple calls to stateful closures to verify that their state behaves as expected.
  • Use classes when the state and behavior become too complex for a simple closure.
  • Avoid retaining large objects in closures longer than necessary.
  • Prefer clear code over complicated nested function structures.

20. Mini Project: Configurable Discount Calculator

Project objective: Create a discount calculator using closures. Each calculator should retain a configured discount rate and calculate discounted prices without using a global variable.

Requirements

  • Create a function called make_discount_calculator().
  • Accept a discount percentage as an argument.
  • Return an inner function that calculates the final price.
  • Create separate calculators for different discount rates.
  • Display the calculated prices.

Solution

def make_discount_calculator(discount_rate):
    def calculate_discounted_price(price):
        discount = price * discount_rate
        return price - discount

    return calculate_discounted_price

ten_percent_discount = make_discount_calculator(0.10)
twenty_percent_discount = make_discount_calculator(0.20)

print(ten_percent_discount(1000))
print(twenty_percent_discount(1000))
print(ten_percent_discount(2500))

Output:

900.0
800.0
2250.0

Explanation: The outer function receives a discount rate and returns an inner function. Each returned function retains its own discount rate, making it possible to create different calculators from the same implementation.

Possible Improvements

  • Validate that the discount rate is between 0 and 1.
  • Reject negative prices if the business rules do not permit them.
  • Round the result according to the application's currency rules.
  • Write unit tests for zero discount, full discount, and invalid input.

21. Practice Exercises

  1. Create an outer function that stores a user's name and returns a function that displays a personalized message.
  2. Write a closure that converts temperatures using a configurable offset.
  3. Create a counter closure that supports both incrementing and reading the current count.
  4. Build a function factory that creates multipliers for 2, 5, and 10.
  5. Write a decorator that measures and prints how long a function takes to run.
  6. Fix a late-binding issue in a list of lambda functions created inside a loop.
  7. Write two closures with independent state and demonstrate that they do not share their counters.
  8. Create a closure-based tax calculator that retains a configured tax rate.
  9. Compare a closure-based counter with a class-based counter and identify when each design is appropriate.

22. Interview Questions and Answers

Q1. What is lexical scope in Python?

Lexical scope means that the enclosing scopes available to a function are determined by where the function is defined in the source code.

Q2. What is a closure?

A closure is a function that retains access to variables from an enclosing function scope, even after the outer function has returned.

Q3. How does Python resolve variable names?

Python commonly follows the LEGB lookup rule: Local, Enclosing, Global, and Built-in.

Q4. What is the difference between a nested function and a closure?

A nested function is defined inside another function. When an inner function retains access to variables from its enclosing scope, it exhibits closure behavior.

Q5. Why are closures useful?

Closures are useful for encapsulating configuration, maintaining state, creating function factories, implementing decorators, and building callbacks.

Q6. What does the nonlocal keyword do?

nonlocal allows a nested function to rebind a name defined in an enclosing function scope.

Q7. What is the difference between global and nonlocal?

global refers to the module-level namespace, while nonlocal refers to a binding in the nearest enclosing function scope.

Q8. Can a closure access variables after the outer function returns?

Yes. The closure retains access to the enclosing bindings it references, allowing it to use them after the outer function call has completed.

Q9. What is late binding in closures?

Late binding occurs when a closure looks up a captured variable when the function is called, rather than saving a separate value when the function is created. It commonly causes surprises in loops.

Q10. How can late binding be avoided?

Use a default argument to capture a value when creating a function, or use a function factory that creates a separate enclosing scope for each value.

Q11. How are closures used in decorators?

A decorator's wrapper function can retain access to the original function and use that reference when the wrapper is called.

Q12. Are closures always better than classes?

No. Closures work well for simple encapsulated behavior and configuration. Classes are often clearer when an application requires several state variables and related methods.

23. Quick Revision Checklist

  • Scope determines where a name can be accessed.
  • Python uses the LEGB rule for ordinary name lookup.
  • Lexical scope is determined by the function's definition context.
  • Nested functions can access variables from enclosing function scopes.
  • Closures retain access to referenced enclosing variables.
  • nonlocal rebinds an enclosing function variable.
  • global refers to the module-level namespace.
  • Function factories use closures to create configured functions.
  • Closures can preserve state between calls.
  • Decorators and callbacks commonly use closure behavior.
  • Late binding can cause unexpected results in loops.
  • Clear, focused closures are easier to test and maintain.

24. Conclusion

Closures and lexical scope are fundamental concepts for understanding how Python manages variable visibility and nested functions. Lexical scope determines where names are resolved, while closures allow functions to retain access to selected enclosing bindings after the outer function returns.

These concepts are especially valuable for building function factories, counters, decorators, callback functions, and configurable operations. Understanding the LEGB rule, the nonlocal keyword, and late binding helps developers avoid common errors and write more maintainable Python code.

Continue learning with the Python Training Course at JustAcademy, register for a course demo, or visit the JustAcademy homepage.

25. Additional Learning Resources

whatsapp