Python Variable Shadowing Explained with Examples
Learn how Python variable shadowing works, why it can cause subtle 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, a plain assignment to a name inside a function creates a new local variable, even if an outer-scope variable 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 risk applies to module names. If you assign math or json to something else, that name no longer refers to the module, so later code that calls math.sqrt or json.dumps will fail. To keep using the module after such a name conflict, import it under an alias or avoid the shadowing name.
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 to the expression. One exception is Python 3.8+: an assignment expression (the walrus operator) inside a comprehension or generator expression binds in the enclosing scope, so it can affect names outside 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. Linters can help catch accidental shadowing: Pylint has redefined-outer-name and redefined-builtin checks, and Flake8 plugins such as flake8-builtins can flag built-in name reuse. Enabling a linter in CI helps catch these problems before they reach 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.
Performance and Memory Misconceptions
Shadowing itself does not create a memory leak. A shadowed outer variable remains reachable through its original scope, and the inner name simply refers to a different object. The main runtime risk is different: if you shadow a built-in or module name, later code that assumes the original meaning can fail with a TypeError or AttributeError. The largest practical cost of accidental shadowing is usually maintainability, not execution speed.