Python Variable Scope & Lifetime: LEGB Rule Explained

Technogic profile picture By Technogic
Thumbnail image for 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:

  1. L – Local: Inside the current function

  2. E – Enclosing: Any outer (nested) functions

  3. G – Global: At the module’s top level

  4. B – Built-in: Names like print() and len() 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
  1. inner() prints "local" — the name resolves to its own scope.

  2. After that, outer() prints "enclosing".

  3. 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 the greet() 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 nonlocal sparingly

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:

  1. Local

  2. Enclosing

  3. Global

  4. 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 del

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-level counter, 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 the count defined in outer(). Without nonlocal, inner() would create its own local count, leaving outer()’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

Global (module-level)

✅ Yes

Managing global state or config values

nonlocal

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 or nonlocal.

  • 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

  1. 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.

  2. 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.

  3. Use Descriptive Naming

    Choose clear names that reflect the variable’s role, avoiding generic placeholders like x, df, or temp. Also, don’t override built-in names like list or len—that leads to confusing bugs.

  4. Keep Scope Narrow

    Define variables as close as possible to their use-case. This enhances clarity and prevents unintended interactions.

Common Pitfalls

  1. 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.

  2. Shadowing Globals Unintentionally

    Assigning to a name inside a function without declaring it as global conceals the global variable—this can lead to UnboundLocalError or confusion.

  3. Mutating Globals Without global Keyword

    Modifying a global variable inside a function without a global declaration often triggers errors or unintended behaviors. Always use global when you intend to reassign a global variable.

  4. Overuse of global or nonlocal

    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 and nonlocal 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.