Python Variable Shadowing Explained with Examples
python variable shadowing: Understand how Python variable shadowing works, where it causes bugs, and how to avoid it with clear naming and scoping discipline.
Python variable shadowing occurs when a name in an inner scope is bound to a new value, hiding a variable with the same name from an outer scope. This behavior follows directly from Python's name resolution rules, and while it can be intentional, it frequently leads to subtle bugs and confusing code. Consider this minimal example:
x = 10 def outer(): x = 20 def inner(): x = 30 print(x) inner() print(x) outer() print(x)
The output is 30, 20, and 10. Each assignment creates a new binding in the current scope, so the inner function sees its own x, the outer function sees its own, and the module-level x remains untouched. This is the essence of shadowing: a name in a narrower scope replaces the meaning of the same name from a wider scope.
How Python Resolves Names: The LEGB Rule
Python resolves names using the LEGB order: Local, Enclosing, Global, Built-in. When you reference a name, the interpreter searches the local scope first, then any enclosing function scopes, then the global (module) scope, and finally the built-in scope. Shadowing happens when a name is found in an earlier scope, so the later scopes are never consulted.
value = "global" def outer(): value = "enclosing" def inner(): value = "local" return value return inner() print(outer()) # local
This resolution order is the reason why a local variable with the same name as a global variable does not raise an error. It also explains why you must use the global or nonlocal keyword to modify a variable from an outer scope. Without those keywords, an assignment inside a function always creates a new local variable, even if a global of the same name exists.
Shadowing Built-in Names and Modules
A common and particularly damaging form of shadowing is redefining a built-in name. If you assign a value to list, dict, type, or id, you hide the built-in function from that point onward in the current scope.
list = [1, 2, 3] print(list) # Later, this fails: # list((4, 5)) # TypeError: 'list' object is not callable
The assignment shadows the built-in list constructor. This often happens accidentally when a developer uses a common name as a variable. The failure may not occur immediately, but it will surface in another part of the module or in a function that relies on the built-in. The same applies to module names: if you name a variable math or json, you cannot import or use that module later in the same scope.
Common Bugs: Shadowing in Loops and Comprehensions
Loop variables are a frequent source of shadowing bugs, especially when a variable name is reused across nested loops or with an existing variable.
index = 10 for index in range(3): print(index) print(index) # 2, not 10
The loop variable index shadows the outer variable and remains bound to the last value after the loop ends. In Python 3, list comprehensions have their own local scope for the iteration variable, but the same is not true for regular for loops. This difference can cause unexpected behavior when you assume a loop variable is isolated.
i = 5 squares = [i * i for i in range(3)] print(i) # 5, because comprehension variable is local
In a generator expression, the iteration variable is also local, but the surrounding scope can still be affected if you use an assignment expression (the walrus operator) inside the expression.
Intentional Shadowing: Parameters and Local Variables
Shadowing is not always a mistake. Function parameters intentionally shadow global names, allowing you to write a function without worrying about the global namespace.
config = {"debug": True} def process(config): print(config["debug"]) process({"debug": False})
Here the parameter config shadows the global config inside the function. This is a standard and readable pattern. Similarly, local variables that are derived from parameters often shadow outer names for clarity. The danger arises when the shadowing is accidental, or when the same name is used for two different concepts in the same scope chain.
Avoiding Shadowing: Naming Conventions and Refactoring
The most reliable way to avoid accidental shadowing is to use distinct, descriptive names. Instead of list, use items or entries. Instead of index, use position or i only in tight loops where the scope is obvious. When a function parameter needs to differ from a global, prefix the global with an underscore or use a module-level constant in ALL_CAPS.
MAX_RETRIES = 3 def connect(retries): if retries > MAX_RETRIES: raise ValueError("Too many retries")
Refactoring a large function into smaller helpers also reduces the chance of shadowing, because each helper has a narrower scope. Tools like linters (pylint, flake8) can detect shadowing when configured with rules such as redefined-outer-name or redefined-builtin. Enabling these rules in CI helps catch accidental shadowing before it reaches production.
Maintainability and Debugging Concerns
Shadowing directly harms maintainability. When a reader sees a variable name, they must determine which scope it refers to, which requires tracing the entire function and any enclosing scopes. This cognitive load increases with nesting depth and with the reuse of common names. Debugging becomes harder because an assignment in a nested scope can silently change the meaning of a name without raising any error.
Consider a function that accidentally shadows a global used elsewhere:
threshold = 0.5 def check(data): threshold = 0.8 # local, not the global return [x for x in data if x > threshold]
The developer may have intended to update the global threshold, but instead created a local variable. The function works, but the global remains unchanged. This kind of bug is difficult to spot because the code runs without errors. Using global or nonlocal explicitly makes the intent clear, but overusing them can create coupling between scopes. The best approach is to avoid reusing names across scopes unless the shadowing is deliberate and well-documented.
Shadowing in Class and Instance Scopes
Class bodies have their own scope, but methods do not automatically see class-level variables. If you define a variable in a class body and then reference it inside a method without self, Python looks for a global, not a class attribute. This can lead to accidental shadowing when a method parameter has the same name as a class attribute.
class Service: timeout = 30 def connect(self, timeout): print(timeout) # parameter, not class attribute print(Service.timeout) # explicit class access
To access the class attribute, you must use Service.timeout or self.timeout (if the instance does not shadow it). This is not variable shadowing in the strict sense, but it is a related scoping confusion that developers often encounter. The same principle applies to instance attributes: assigning self.timeout creates an instance attribute that shadows the class attribute for that instance.
When Shadowing Affects Performance and Memory
Shadowing itself has no direct performance cost; Python resolves names at runtime regardless of how many scopes are involved. However, the consequences of shadowing can affect performance indirectly. If you accidentally shadow a built-in function and then call it in a tight loop, Python raises a TypeError instead of executing the function, causing a crash. If you shadow a module name, you may trigger an import error at a later point. These failures are not performance issues, but they can cause wasted computation before the error occurs.
More relevant is the memory impact of keeping references to large objects in outer scopes when an inner scope shadows them. For example, if a global list is shadowed by a local variable, the global list still exists in memory, but the local variable points to a new object. This is not a leak, but it can confuse profiling if you expect the global to be reused. In practice, the memory footprint is negligible unless you are creating many large objects in a loop that also shadows a global reference.
The real cost of shadowing is in developer time: reading, debugging, and refactoring code that relies on subtle scope interactions. A codebase that avoids accidental shadowing is easier to reason about and less prone to regressions when a variable is renamed or a scope is changed.