Python Variable Scope & Lifetime: LEGB Rule Explained
Introduction
When writing Python programs, variables are everywhere—holding values, tracking state, and enabling logic. But have you ever wondered where a variable is accessible or how long it actually lives in memory?
This is where two crucial concepts come into play: scope and lifetime.
Scope determines the region of your code where a variable can be used.
Lifetime refers to the duration for which the variable exists in memory—from creation to destruction.
Understanding these principles is essential for writing clean, modular, and bug-free code. Without them, you might unintentionally modify global state, overwrite important values, or run into confusing errors where variables “vanish” or behave unexpectedly.
In this post, we’ll explore how Python handles variable scope through the LEGB rule, distinguish between local, global, and nonlocal variables, and understand how Python manages variable lifetimes behind the scenes.
Whether you’re debugging unexpected behavior or optimizing your program’s structure, mastering scope and lifetime will empower you to write smarter and safer Python code.
The LEGB Rule (Scope Hierarchy)
Python uses a well-defined order known as the LEGB rule to determine where to look for variable names during execution:
L – Local: Inside the current function
E – Enclosing: Any outer (nested) functions
G – Global: At the module’s top level
B – Built-in: Names like
print()
andlen()
provided by Python
How Name Lookup Works
When you reference a variable, Python searches in this order:
Local scope – the function you're currently in
Enclosing scope – one level up, if you're inside a nested function
Global scope – at the top of your module
Built-in scope – Python’s standard names
If the name isn't found in any of these scopes, Python raises a NameError
.
Examples at Each Scope Level
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x)
inner()
print(x)
outer()
print(x)
Output:
local
enclosing
global
inner() prints
"local"
— the name resolves to its own scope.After that, outer() prints
"enclosing"
.Finally, the global
print(x)
shows"global"
.
Why the LEGB Rule Matters
Avoid unintended variable shadowing Using the same variable name in multiple scopes can lead to confusion—knowing LEGB helps you predict which value Python uses.
Supports nested functions & closures You can access, but not modify, names from enclosing functions—unless you use
nonlocal
. This enables powerful patterns like closures.Clarifies intent in your code Writing code that respects scope boundaries improves readability—especially in larger, multi-module projects.
Quick Takeaway
Python always searches Local → Enclosing → Global → Built-in
Each function call creates a new local scope
Carefully choosing where to declare variables prevents bugs and enhances clarity
Local Variables
Local variables are the lifeblood of functions—they’re created, used, and then disappear. Here's why they matter:
What Are Local Variables?
Any variable assigned inside a function is local to that function—it only lives during that function’s execution and isn’t accessible elsewhere. This applies to parameters as well.
Example:
def greet(): message = "Hello, world!" # message is a local variable print(message) greet() print(message) # ➜ NameError: name 'message' is not defined
The name
message
exists only during thegreet()
execution.
Lifetime: Creation and Destruction
A local variable is created when the function is called and destroyed immediately after it returns.
Each function call creates a fresh set of local variables—even if the same function is called multiple times.
Why It Matters
Locals prevent unintended side effects because they don’t leak into other parts of your program.
Since they’re temporary, they help manage memory usage, and Python frees them automatically.
Using locals ensures each call has its own isolated workspace—essential for correctness and clarity.
Key Takeaways
Local variables (including parameters) are confined to their function scope.
They exist only while the function runs—and are removed after it completes.
Each invocation gets its own locals—even when calling the same function repeatedly.
Understanding locals is the first step in managing variable scope and lifetime effectively. Next, we'll dive into how global variables compare—and how to control them when needed.
Global Variables
Global variables are defined outside of any function, at the module (top-level) scope, making them accessible throughout the entire module and even across modules (when imported).
Accessing Global Variables Inside Functions
You can read a global variable inside a function without any special keyword:
message = "Hello, world!"
def greet():
print(message) # reads the global variable
Both greet()
and global code can access message
because it’s defined at the top level.
Shadowing by Local Assignment
If you assign to a variable inside a function, Python treats it as a new local variable, even if one with the same name exists globally:
name = "Alice"
def show():
name = "Bob"
print(name) # prints "Bob", local only
show()
print(name) # prints "Alice", global stays unchanged
Here, the local name
shadows the global name
inside show()
.
Modifying Global Variables with global
To modify an existing global variable inside a function, you must declare it using global
:
count = 0
def increment():
global count
count += 1
increment()
print(count) # prints 1
Without global
, attempting assignment would trigger an UnboundLocalError
, as Python would treat count
as a new, uninitialized local variable.
Mutating Global Mutable Objects
You can mutate global mutable objects (like lists, dicts) inside functions without using global
, since you're not rebinding the name—just changing the object itself:
numbers = []
def add_number(n):
numbers.append(n) # mutating the global list
add_number(10)
print(numbers) # [10]
This works fine because the name numbers
isn't reassigned—only the object it references is modified.
When to Use (or Avoid) Globals
Use sparingly—globals introduce hidden dependencies and can cause bugs that are hard to trace.
Prefer passing values via function arguments and returning results instead—this supports modular and testable code.
Use
global
explicitly only when necessary (e.g., managing shared state or simple scripts that require it).
Summary
Global variables are accessible anywhere in the module.
You can read them inside functions without any declaration.
To reassign, you must use the
global
keyword.Mutating global mutable objects is allowed without
global
.Use global variables carefully—explicit is better than implicit.
Understanding global variables helps you recognize and avoid potential pitfalls in program design and maintain cleaner, safer code. Next up, we’ll explore how enclosing scopes and the nonlocal
keyword empower nested functions to manage inner state.
Enclosing (Nonlocal) Scope
In Python, nested functions can access variables defined in their enclosing function—this is known as the enclosing or nonlocal scope and sits between the local and global scopes in the LEGB rule.
What Is Enclosing Scope?
When you define a function inside another function:
The outer function's local variables form the enclosing scope for the inner function.
The inner function can read these variables even without passing them as arguments:
def outer(): text = "Hello, enclosing!" def inner(): print(text) # reads from enclosing scope inner() outer()
This is a powerful feature for creating closures or helper functions.
Modifying Enclosing Variables with nonlocal
If you want to reassign an enclosing variable, you must use the nonlocal
keyword—otherwise, Python treats the name as new or raises an error:
def outer():
count = 0
def inner():
nonlocal count
count += 1
print("Inner count:", count)
inner()
print("Outer count still:", count)
outer()
# Output:
# Inner count: 1
# Outer count still: 1
Here, nonlocal count
tells Python that count
refers to the variable in the outer()
scope, not a new local one.
Seeing It in LEGB
The LEGB lookup goes like this:
Local: variables inside
inner()
Enclosing: variables in
outer()
Global: variables at module top-level
Built‑in: names like
print
,len
By declaring nonlocal
, Python knows to search one scope up when you assign to a variable in the nested function.
Why It Matters
Avoids workaround with globals by allowing inner functions to update outer context.
Crucial for writing closures that retain and update state without polluting the global namespace .
Helps encapsulate logic and state in hierarchical functions.
Best Practices
Recommendation | Reason |
---|---|
Use | Simplifies state management in closures |
Favor local/state passing | Prefer clear argument passing unless closure is intentional |
Avoid deep nesting | Too many nested scopes can become difficult to understand |
Summary
Enclosing scope exists when you nest one function inside another.
Use nonlocal to modify variables in that enclosing scope.
This enables closures—functions that remember and manipulate data from their enclosing scope.
Next up, we’ll look at built-in scope and then how variable lifetimes determine the lifetime and cleanup of these scoped variables.
Built-in Scope
The built-in scope is Python’s most global namespace, containing names for built-in functions, exceptions, types, and constants (like print
, len
, int
, Exception
, etc.) that are loaded whenever the Python interpreter runs—whether in a script or interactive session.
How Python Uses Built-in Scope
When your code references a name, Python searches in this order:
Local
Enclosing
Global
Built-in
The built-in scope is always checked last. This ensures that your code can rely on Python’s standard library without needing imports for basic functionality like print()
or len()
.
Inspecting Built-ins
Python makes its built-in scope available via the special namespace __builtins__
, which you can inspect:
import builtins
print(len(dir(__builtins__))) # e.g., shows ~150+ built-in names
print(builtins.abs(-5)) # 5, as expected
Avoid Shadowing Built-ins
You can override a built-in name in your global namespace, but this is a discouraged practice:
max = 100
print(max([1, 2, 3])) # TypeError—max is no longer the built-in
del max # restores the built-in max function
Redeclaring built-in names can lead to confusing bugs across your codebase and is not recommended.
Summary
The built-in scope contains Python’s native functions, exceptions, and data types.
It’s the fallback name lookup per the LEGB rule.
Override of built-ins is allowed but discouraged due to potential confusion.
Use
del
to remove an overridden built-in and restore original behavior.
Next, we’ll dive into variable lifetimes—how long these scoped variables actually stick around in memory before being cleaned up.
Variable Lifetime: Creation & Destruction
Understanding how long a variable exists in memory—its lifetime—is key to writing efficient, bug-free Python code. Here’s how it works:
Local Variables
Created when a function is called, and destroyed when the function returns. They don’t persist across calls.
Example:
def compute_area(r): pi = 3.14 # local variable area = pi * r * r return area result = compute_area(5) # After execution, pi and area are discarded
Global Variables
Created when the module is first loaded and remain alive until the program ends or the module is explicitly deleted.
These live throughout the program lifecycle by default.
Enclosing (Nonlocal) Variables
Variables in an outer function scope are created when that outer function is entered and destroyed after it finishes .
Inner functions can access (and optionally modify) them during this period.
Memory Management: Reference Counting & Garbage Collection
Python primarily uses reference counting—each object tracks how many variables reference it. When the count hits zero, that object becomes collectible.
To handle cycles (objects referencing each other), Python adds a generational garbage collector that cleans up these cases.
You can delete names using
del
, freeing that reference—but the object only truly disappears when no references remain.
Using del
del variable_name
removes the name binding.If it was the last reference, the object becomes garbage and may be cleaned up later.
Quick Summary
Scope | Created When | Destroyed When |
---|---|---|
Local | Function call | Function returns |
Enclosing | Outer function call | Outer function returns |
Global | Module import/load | Program exit or |
Built-in | Python interpreter startup | Program exit |
Python’s reference counting and periodic garbage collection ensure memory is reclaimed efficiently—so variables and objects don’t linger once they're no longer needed.
Understanding variable lifetimes helps you write memory-efficient and predictable Python programs. Next, let’s explore how you can use global
and nonlocal
to control variable access more explicitly.
Keywords: global
and nonlocal
Python provides two special keywords—global
and nonlocal
—that allow you to modify variable behavior beyond a function's local scope. Here's how they work and when to use them effectively.
global
Keyword
Purpose: Indicates that a variable assignment inside a function should refer to the global variable (module-level), not a new local one.
Without
global
, assigning to a name inside a function creates a new local variable—even if a global variable with that name exists.
Example:
counter = 0 # Global variable
def increment():
global counter
counter += 1
increment()
print(counter) # Outputs: 1
Here,
global counter
tells Python to update the module-levelcounter
, rather than creating a local one citing an UnboundLocalError if omitted.
⚠️ Caution: Overusing global
can make your code unpredictable and hard to test. It’s usually better to pass state into functions and return results .
nonlocal
Keyword
Purpose: Lets a nested function modify a variable in its enclosing (non-global) scope.
Without
nonlocal
, assigning to the same name creates a new local variable in the inner function, leaving the outer variable unchanged.
Example:
def outer():
count = 0
def inner():
nonlocal count
count += 1
inner()
print(count) # Outputs: 1
Here,
inner()
modifies thecount
defined inouter()
. Withoutnonlocal
,inner()
would create its own localcount
, leavingouter()
’s version untouched.
Uses:
Ideal for working with closures (functions that remember state)
Allows compact, nested logic without resorting to globals
Choosing Between Them
Keyword | Target Variable Scope | Modifies That Scope? | Typical Use Case |
---|---|---|---|
| Global (module-level) | ✅ Yes | Managing global state or config values |
| Nearest enclosing function | ✅ Yes | Managing state in closures |
Use both sparingly. They’re powerful—but can make code harder to reason about.
Best Practices
By default, prefer passing arguments and returning results instead of using
global
ornonlocal
.Only use:
global
for truly shared module-level state (e.g., counters, config).nonlocal
for state tied to closures or nested context.
Always explicitly declare modifications with these keywords to avoid unexpected behavior.
Summary
global
lets a function modify a variable at the module level.nonlocal
enables nested functions to modify a variable from the enclosing scope.Both override Python’s default scoping behavior, so use them thoughtfully.
Best Practices & Common Pitfalls
Effectively managing variable scope helps keep your Python code clean, maintainable, and bug-resistant. Here's a refined guide to best practices and pitfalls based on expert insights:
Best Practices
Minimize Global Variables
Avoid using globals except for true constants (e.g.,
MAX_RETRIES = 5
) or rare shared resources. Overuse leads to unpredictable dependencies and harder-to-test code. Instead, pass data through function arguments and return results—this keeps your logic local and explicit.Write Pure Functions
Aim for pure functions—functions that:
Depend solely on input parameters,
Don’t modify external state or rely on hidden variables. These are easier to test, reason about, and reuse.
Use Descriptive Naming
Choose clear names that reflect the variable’s role, avoiding generic placeholders like
x
,df
, ortemp
. Also, don’t override built-in names likelist
orlen
—that leads to confusing bugs.Keep Scope Narrow
Define variables as close as possible to their use-case. This enhances clarity and prevents unintended interactions.
Common Pitfalls
Late Binding in Closures
Nested functions sometimes capture variables by reference, not by value, leading them to use the last loop value when executed later 👎. Fix: Use default parameters or
functools.partial
to capture current values at definition time.Shadowing Globals Unintentionally
Assigning to a name inside a function without declaring it as
global
conceals the global variable—this can lead toUnboundLocalError
or confusion.Mutating Globals Without
global
KeywordModifying a global variable inside a function without a
global
declaration often triggers errors or unintended behaviors. Always useglobal
when you intend to reassign a global variable.Overuse of
global
ornonlocal
Excessive use of
global
weakens modularity and testability.nonlocal
is sometimes necessary for closures, but require careful handling. Prefer passing state explicitly where possible.
Takeaway Strategies
Declutter your scope: Aim for fewer globals and more pure functions.
Name with clarity: Avoid shadowing and obfuscation.
Guard closures and globals: Use
global
/nonlocal
sparingly and explicitly.Testability first: Functions taking explicit arguments and returning results are easier to test.
By following these guidelines, you'll write code that's modular, predictable, and easier to maintain.
Conclusion
Mastering variable scope and lifetime is essential for writing efficient, readable, and bug-free Python code. Through the LEGB rule, Python provides a predictable structure for name resolution—moving from local, to enclosing, to global, and finally to built-in scopes.
You’ve learned:
How local, global, enclosing, and built-in scopes operate,
When and how to use the
global
andnonlocal
keywords,That variable lifetime depends on scope and is managed by Python’s memory system using reference counting and garbage collection.
By applying best practices like minimizing global variables, using pure functions, and being cautious with closures and scope shadowing, you'll write code that’s more modular, maintainable, and intuitive.
In the long run, understanding these foundational concepts not only helps prevent subtle bugs but also empowers you to structure your programs more thoughtfully and effectively.