Person focused on a large monitor displaying Python code in a dimly lit room with a desk lamp and keyboard. Python’s simple, easy-to-learn syntax can mislead beginners into missing some of its subtleties and underestimating the power of the language . Despite being considered one of the most reliable and stable programming languages , common Python errors continue to trip up even the most advanced developers.

Whether we’re talking about forgetting colons or more complex issues like modifying a list during iteration , these hidden pitfalls can significantly increase development time and cause unexpected issues in production environments . I’ve seen countless developers struggle with seemingly simple concepts like floating point management and infinite loops .

In this guide, we’ll explore seven subtle Python mistakes that aren’t immediately obvious but can cause major headaches. From mutable default arguments to circular imports, I’ll show you not just what goes wrong, but also how to fix these issues for cleaner, more reliable code. Let’s dive into these common Python errors and make sure they don’t sabotage your next project.

Using Mutable Default Arguments

Image

Image Source: LinkedIn

Mutable default arguments represent one of the most surprising behaviors in Python, especially for newcomers to the language. Many developers stumble upon this issue only after hours of debugging unusual behavior in their code. I’ve seen this trap countless times in code reviews and want to help you understand and avoid it.

What is the error with mutable default arguments

The error occurs when you use a mutable object (like a list, dictionary, or set) as a default parameter in a function definition. Consider this innocent-looking function:

def add_item(name, item_list=[]):
    item_list.append(name)
    return item_list

At first glance, you might expect this function to create a new empty list each time it’s called without an item_list parameter. However, running this code reveals something unexpected:

print(add_item("apple"))     # Outputs: ['apple']
print(add_item("banana"))    # Outputs: ['apple', 'banana']

Instead of getting two separate lists with one item each, the second call returns a list containing both items. This happens because the list is being modified across multiple function calls .

Why mutable default arguments cause issues

This behavior stems from how Python handles default arguments. Default arguments are evaluated only once when the function is defined, not each time the function is called . Consequently, when you use a mutable object as a default value, Python creates that object once when loading the function and then reuses the same object for all subsequent calls .

Looking deeper into the mechanics, the default value becomes tied to the function through its __defaults__ attribute. When the function runs and modifies this mutable object, it’s actually changing the default value itself .

Here’s what happens step by step:

  1. Python defines the function and creates the default empty list
  2. The first call uses this list and adds “apple” to it
  3. The second call uses the same list (now containing “apple”) and adds “banana”

Furthermore, this issue applies to all mutable types in Python, including dictionaries:

def update_score(name, score, record={}):
    record[name] = score
    return record

Similarly, each call to this function will modify the same shared dictionary .

How to fix mutable default arguments

The standard solution is to use None as the default argument and then initialize the mutable object inside the function body:

def add_item(name, item_list=None):
    if item_list is None:
        item_list = []
    item_list.append(name)
    return item_list

With this approach, calling the function without specifying an item_list will always create a new empty list . This pattern is extremely common in Python codebases .

Another alternative is to use immutable defaults like tuples instead of lists when possible:

def process_items(items=()):
    # Convert to list inside the function
    return list(items)

For completeness, here’s a comparison of incorrect versus correct approaches:

ApproachCodeResultRecommendation
❌ Incorrectdef func(arg=[])Shared list across callsAvoid
✅ Correctdef func(arg=None) with checkNew list each callRecommended
✅ Alternativedef func(arg=())Immutable defaultGood for simple cases

In addition to using None, another good practice is to explicitly make a copy of any mutable argument passed to your function if you intend to modify it:

def process_list(items=None):
    items = list(items) if items is not None else []
    # Now safe to modify items

This approach offers an additional benefit: it accepts any iterable (not just lists) since the list() constructor works with any iterable object .

In conclusion, while mutable default arguments are a powerful feature, they can lead to confusing bugs that are difficult to track down. By understanding this behavior and applying the patterns above, you’ll avoid one of Python’s most common hidden errors.

Incorrect Use of Class Variables

Image

Image Source: Hamdi Ghorbel – Medium

Class variables in Python often trap developers who come from other programming languages. Unlike mutable default arguments, class variable issues typically emerge unexpectedly during development, causing bugs that manifest in unpredictable ways.

What is the error with class variables

Class variables in Python are attributes defined directly in the class, outside any methods. They’re shared among all instances of that class, functioning somewhat like static variables in other languages.

The error occurs when developers misunderstand this shared nature, particularly with mutable objects like lists or dictionaries. Consider this seemingly innocent code:

class Dog:
    tricks = []  # class variable shared by all dogs
    
    def __init__(self, name):
        self.name = name
        
    def add_trick(self, trick):
        self.tricks.append(trick)
        
fido = Dog('Fido')
buddy = Dog('Buddy')
fido.add_trick('roll over')
buddy.add_trick('play dead')

print(fido.tricks)  # Outputs: ['roll over', 'play dead']

Most developers expect each dog to have its own list of tricks. Nonetheless, both dogs end up sharing the same list . This happens because tricks is a class variable—a single copy shared among all instances of the Dog class.

Why class variables behave unexpectedly

Class variables behave this way primarily because of Python’s attribute lookup mechanism. When you access an attribute through an instance, Python first checks if that attribute exists in the instance’s dictionary. If not found, it then searches in the class dictionary.

This lookup process creates three particularly confusing behaviors:

  1. Mutation vs. Assignment: When you modify a mutable class variable (like appending to a list), the change affects all instances. However, assigning a new value to the same name creates an instance variable that shadows the class variable .
  2. Inheritance Surprises: Class variables are inherited by subclasses. If a parent class’s variable changes, all child classes that haven’t overwritten that variable will reflect the change .
class Parent:
    class_attr = 'parent'

class Child1(Parent):
    pass

class Child2(Parent):
    pass

Child1.class_attr = 'child1'  # Creates attribute in Child1
Parent.class_attr = 'parent_new'  # Updates Parent and Child2

# Result: Parent.class_attr = 'parent_new'
#         Child1.class_attr = 'child1'
#         Child2.class_attr = 'parent_new'
  1. Dictionary Shadowing: Each class maintains its own dictionary of attributes. You can inspect this using __dict__, which reveals whether a class stores an attribute or inherits it .

Furthermore, class variables create particularly treacherous bugs with mutable types. If one object modifies the class variable, all objects start referencing the modified version—a side effect most developers don’t anticipate [10].

How to fix class variable misuse

The correct approach depends on your intended behavior:

  1. For Instance-Specific Data: Move the variable initialization to the constructor (__init__):
class Dog:
    def __init__(self, name):
        self.name = name
        self.tricks = []  # Each dog gets its own list
        
    def add_trick(self, trick):
        self.tricks.append(trick)
  1. For Truly Shared Data: Use class variables for constants or configuration shared by all instances, but modify them through the class name, not through instances [10]:
class Player:
    club = "FC Barcelona"  # Shared by all players
    
    @classmethod
    def change_club(cls, new_club):
        cls.club = new_club  # Proper way to modify class variables
  1. For Complex Cases: Consider using Python’s dataclasses module, which handles much of this boilerplate automatically [11]:
from dataclasses import dataclass

@dataclass
class A:
    x: int = 100  # Creates proper instance variables with defaults
    y: int = 200

Here’s a comparison of approaches:

ScenarioIncorrect ApproachCorrect Approach
Instance-specific dataUsing class variablesInitialize in __init__
Shared configurationModifying through instancesModify via class name or @classmethod
Complex classesManual attribute handlingUse dataclasses

Overall, the key insight is recognizing when you need independent data for each instance versus truly shared data across all instances. When in doubt, initialize variables in __init__ to avoid unexpected behavior.

Modifying a List While Iterating

Image

Image Source: Stack Overflow

Confusing Variable Scope in Functions

Image

Image Source: BTech Smart Class

Variable scope confounds even experienced Python developers, causing mysterious errors that seem to defy logic. Throughout my years of teaching Python, I’ve noticed that scope-related bugs are often the most difficult for beginners to diagnose and fix.

What is the error with variable scope

Variable scope defines where in your code a variable can be accessed or modified. The error typically manifests as a NameError or more specifically, an UnboundLocalError that occurs when Python can’t find a variable you’re trying to use.

Consider this seemingly straightforward code:

counter = 0  # Global variable

def update_counter():
    counter = counter + 1  # Trying to modify the global counter
    print(counter)

update_counter()  # Raises: UnboundLocalError: local variable 'counter' referenced before assignment

This code fails even though counter is clearly defined before the function. What’s happening? Python sees the assignment to counter inside the function and treats it as a local variable. Yet when it tries to calculate counter + 1, the local counter doesn’t exist yet—hence the error.

A similar error occurs when trying to access function-local variables from outside:

def my_function():
    result = 42  # Local variable
    
print(result)  # Raises: NameError: name 'result' is not defined

The variable result exists only within the function’s local scope and vanishes once the function returns.

Why scope confusion happens in Python

Scope confusion typically stems from Python’s LEGB rule—the sequence Python follows when resolving variable names:

  1. Local: Variables defined inside the current function
  2. Enclosing: Variables in the local scope of enclosing functions
  3. Global: Variables defined at the module level
  4. Built-in: Python’s pre-defined names like len and print

This lookup hierarchy creates several tricky situations:

Variable shadowing occurs when a local variable has the same name as a variable in a higher scope. In this case, the local variable “shadows” the global one, making the global inaccessible inside the function:

x = 10  # Global
def shadow_example():
    x = 20  # Local shadows the global
    print(f"Local x: {x}")

shadow_example()  # Outputs: Local x: 20
print(f"Global x: {x}")  # Outputs: Global x: 10

Assignment behavior makes Python treat variables differently depending on whether you assign to them inside a function. If you only read a global variable, Python finds it in the global scope. However, as soon as you assign to it, Python creates a new local variable with the same name.

Scope determination at compile time means that once Python decides a variable is local (because of an assignment somewhere in the function), it treats that variable as local throughout the entire function—even in code that executes before the assignment.

How to fix scope-related bugs

Several strategies can help you resolve scope-related errors:

  1. Use the global keyword when you need to modify a global variable inside a function:
counter = 0

def update_counter():
    global counter  # Declare counter as global
    counter = counter + 1
    print(counter)

update_counter()  # Works correctly now
  1. Use nonlocal for nested functions when you need to modify a variable from an enclosing scope:
def outer():
    x = 10
    def inner():
        nonlocal x  # Access x from outer function
        x = 20
    inner()
    print(x)  # Outputs: 20

outer()
  1. Prefer passing arguments and returning values over modifying global state:
def better_update_counter(current_value):
    return current_value + 1

counter = 0
counter = better_update_counter(counter)
  1. Be consistent with naming to avoid variable shadowing. Use descriptive names that make the purpose clear:
total_sum = 0  # Global state clearly named
def process_data(values):
    local_result = sum(values)  # Distinct name for local variable
    return local_result

The following table compares how variables behave in different scopes:

ActionLocal VariableGlobal VariableBest Practice
Read-only accessAvailable only inside functionAvailable everywhereUse parameters for inputs
ModificationChanges lost after function exitsChanges persist across function callsReturn values instead of modifying globals
ShadowingCan shadow global variablesCan be shadowed by localsUse distinct names

Generally, following these principles leads to more maintainable code:

  • Minimize the use of global variables—they make code harder to understand and debug
  • When you must use global state, explicitly declare it with the global keyword
  • Design functions that take inputs as parameters and return outputs rather than modifying external state
  • Consider function scope as a protective boundary, not an obstacle to work around

By understanding Python’s scoping rules, you’ll avoid hours of frustrating debugging and write more robust, maintainable code.

Late Binding in Closures

Image

Image Source: LinkedIn

Closures in Python introduce a subtle yet powerful source of bugs that typically emerge when working with callbacks or functional programming patterns. I’ve watched many Python developers struggle with this concept, often spending hours debugging code that looks perfectly reasonable at first glance.

What is the error with closures and late binding

The error occurs when a nested function (closure) captures variables from its enclosing scope, yet the values aren’t bound when you might expect. Consider this seemingly straightforward code that creates a list of multiplier functions:

def create_multipliers():
    multipliers = []
    for i in range(5):
        multipliers.append(lambda x: i * x)
    return multipliers

# Use the multipliers
multipliers = create_multipliers()
for multiplier in multipliers:
    print(multiplier(2))

You might expect this code to output 0, 2, 4, 6, 8 – each function multiplying by a different value of i. Instead, it prints 8, 8, 8, 8, 8 – every function multiplies by 4 (the final value of i).

This happens because Python’s closures are late binding, meaning they don’t capture the value of variables but rather reference the variables themselves. All five functions end up sharing the same variable i, which has the value 4 by the time any of them are called.

Importantly, this isn’t unique to lambda functions. The same behavior occurs with regular functions:

def create_multipliers():
    multipliers = []
    for i in range(5):
        def multiplier(x):
            return i * x
        multipliers.append(multiplier)
    return multipliers

Why late binding causes unexpected behavior

Late binding means variable values in closures are evaluated when the inner function is called, not when it’s defined. This creates several confusing situations:

  1. Shared variable references – All closures from the same scope reference the same variable, not independent copies of its value
  2. Value at execution time – The value used is whatever the variable contains when the function is finally executed
  3. Loop variables – Particularly problematic in loops where the variable changes with each iteration

This behavior stems from Python’s scoping rules and how it handles closure variables. When Python encounters a variable in a function, it searches through scopes in the LEGB order (Local, Enclosing, Global, Built-in). For closures, the variable from the enclosing scope is used – but it’s the variable itself, not its value at definition time.

Let’s visualize what happens in our multiplier example:

  • The loop creates five functions, each closing over the same variable i
  • By the time we call any of these functions, the loop has completed with i = 4
  • When each function executes i * x, they all look up the current value of i (which is 4)

How to fix closure-related issues

Fortunately, several approaches can solve this problem:

1. Use default arguments to capture current values:

def create_multipliers():
    return [lambda x, i=i: i * x for i in range(5)]

This works because default arguments are evaluated at function definition time, creating an “early binding” effect. Each lambda gets its own independent copy of i.

2. Use functools.partial:

from functools import partial
from operator import mul

def create_multipliers():
    return [partial(mul, i) for i in range(5)]

The partial function creates a new function with some arguments already fixed, effectively binding the value immediately.

3. Create a factory function:

def create_multiplier(i):
    def multiplier(x):
        return i * x
    return multiplier

def create_multipliers():
    return [create_multiplier(i) for i in range(5)]

Each call to the factory creates a new function with its own enclosing scope containing a different value of i.

Here’s a comparison of approaches:

ApproachProsCons
Default argumentsConcise, clear intentSlightly non-intuitive syntax
functools.partialExplicit, functional styleRequires additional imports
Factory functionMost explicit, readableMore verbose

Ultimately, understanding late binding in Python helps prevent a whole class of subtle bugs. Whenever you create closures that reference variables that might change before the closure is called, remember to consider one of these binding solutions.

Circular Module Imports

Image

Image Source: GitHub

Many Python programmers first encounter circular imports after their projects grow beyond a handful of files. This notorious error appears seemingly out of nowhere, baffling both novice and intermediate developers with cryptic error messages.

What is the error with circular imports

Circular imports occur when two or more Python modules depend on each other, creating a dependency loop. For example, imagine a project with two files:

# module_a.py
from module_b import function_b

def function_a():
    print("Function A")
    function_b()

# module_b.py
from module_a import function_a

def function_b():
    print("Function B")
    function_a()

Running either file typically produces an error message like:

ImportError: cannot import name 'function_a' from partially initialized module 'module_a' (most likely due to a circular import)

This happens because neither module can fully initialize without the other being completed first, creating a deadlock situation. The error message explicitly mentions “circular import,” giving us a clue about what’s wrong.

Why circular imports break Python modules

To understand why circular imports fail, we need to examine how Python’s import system works. When Python imports a module, it follows these steps:

  1. Checks if the module is already in sys.modules (Python’s cache of loaded modules)
  2. If not, creates a new module object and adds it to sys.modules
  3. Executes the module’s code from top to bottom
  4. Returns the fully initialized module

The problem arises in step 3. Consider what happens with our example:

  1. Python starts importing module_a
  2. It encounters from module_b import function_b and pauses to import module_b
  3. While importing module_b, it sees from module_a import function_a
  4. Python checks sys.modules and finds module_a is already there, but only partially initialized
  5. It tries to access function_a, but since module_a‘s code hasn’t finished executing, the function doesn’t exist yet
  6. This results in the ImportError

Essentially, circular imports create a chicken-and-egg problem that Python cannot resolve. Interestingly, the error usually appears at the second import statement that completes the circle, not necessarily at the “cause” of the circular dependency.

How to fix circular import problems

Fortunately, several effective strategies exist to eliminate circular imports:

1. Restructure your code

The most robust solution involves reorganizing your modules to eliminate mutual dependencies. Consider:

  • Moving shared code to a third module that both can import
  • Consolidating tightly coupled modules into a single file
  • Improving your application’s architectural boundaries

2. Move imports inside functions

Instead of importing at the module level, move imports inside the functions that actually need them:

# module_a.py
def function_a():
    from module_b import function_b  # Import inside function
    print("Function A")
    function_b()

This works because the import occurs only when the function executes, after both modules have been loaded.

3. Import the entire module

Rather than importing specific attributes, import the whole module:

# module_a.py
import module_b  # Import the module, not specific functions

def function_a():
    print("Function A")
    module_b.function_b()

This approach sometimes works because Python only needs to create the module object initially, not resolve specific attributes.

4. Use dynamic imports

For more complex cases, leverage Python’s importlib module:

def function_a():
    import importlib
    module_b = importlib.import_module('module_b')
    print("Function A")
    module_b.function_b()
SolutionProsConsBest For
Restructure CodeMost robust, improves designRequires significant refactoringLong-term projects
Function-level ImportsSimple, minimal changesCan impact performanceQuick fixes
Import Whole ModuleClean, minimal changesMay not work in all casesMedium complexity
Dynamic ImportsWorks in complex scenariosMore verbose codeLegacy codebases

In practice, the first approach (restructuring) creates the most maintainable code, whereas the others might serve as temporary solutions until proper refactoring can be done.

By identifying and fixing circular imports early, you’ll create more maintainable Python code and avoid mysterious errors that can waste hours of debugging time.

Misusing the del Method

Image

Image Source: Stack Overflow

The destructor method __del__ remains one of Python’s most deceptive features, frequently leading developers down a path of subtle bugs that only emerge under specific circumstances. Unlike other Python errors, destructor issues often manifest silently, making them particularly dangerous.

What is the error with del in Python

The __del__ method serves as a destructor called when an object is garbage collected—when its reference count reaches zero. Consider this seemingly innocent code:

class TempFile:
    def __init__(self, path):
        self.path = path
        
    def __del__(self):
        os.remove(self.path)  # Attempt to delete the file

This implementation contains several hidden dangers. Most importantly, Python doesn’t guarantee when—or even if—__del__ will run. Additionally, exceptions in __del__ methods are typically ignored, printed to stderr without raising errors in your program.

Why del can cause hidden bugs

The unpredictability of __del__ creates numerous problems:

  1. Circular references can prevent __del__ from being called at all
  2. Module imports may already be unloaded when __del__ executes during interpreter shutdown
  3. Silent exceptions make debugging nearly impossible
  4. Resurrection can occur when __del__ creates new references to the object
  5. Threading issues arise when __del__ attempts to signal waiting threads

Moreover, using __del__ for critical cleanup is risky. In one documented case, a program couldn’t be stopped with CTRL+C because KeyboardInterrupt exceptions were being ignored during __del__ execution.

How to fix del misuse with atexit

Alternatively, better approaches exist:

Use context managers to make resource lifetimes explicit:

class TempFile:
    def __enter__(self):
        return self
        
    def __exit__(self, exc_type, exc_val, exc_tb):
        os.remove(self.path)  # Guaranteed cleanup

Subsequently, you can use this with Python’s with statement:

with TempFile("temp.dat") as f:
    # Work with the file

For module-level cleanup, the atexit module provides guaranteed execution during normal interpreter shutdown:

import atexit

def cleanup():
    # Safe cleanup code here
    
atexit.register(cleanup)

Indeed, the consensus among experienced Python developers is that properly implemented context managers represent the most “Pythonic” approach to resource management.

Comparison Table

Error TypeError DescriptionCommon SymptomsPrimary CauseRecommended Fix
Mutable Default ArgumentsUsing mutable objects as default parameters in functionsSame object being modified across multiple function callsDefault arguments evaluated only once when function is definedUse None as default and initialize mutable object inside function
Incorrect Class VariablesMisuse of variables defined directly in classUnexpected shared state between class instancesClass variables being shared among all instances of the classInitialize instance-specific data in init method
Modifying List While IteratingNot mentioned in detailNot mentioned in detailNot mentioned in detailNot mentioned in detail
Confusing Variable ScopeIncorrect handling of variable accessibility in functionsUnboundLocalError or NameErrorPython treating variables as local when assigned within functionUse ‘global’ keyword or pass arguments and return values
Late Binding in ClosuresNested functions capturing variables from enclosing scope incorrectlyAll functions sharing same variable valueVariables evaluated when inner function is called, not when definedUse default arguments or functools.partial
Circular Module ImportsModules depending on each other creating dependency loopsImportError with “circular import” messageModules unable to fully initialize due to mutual dependenciesRestructure code or move imports inside functions
Misusing del MethodIncorrect use of Python’s destructor methodSilent failures, unpredictable cleanupNo guarantee when or if del will runUse context managers with enter/exit methods

Conclusion

These seven hidden Python errors represent some of the most challenging pitfalls developers face when working with this otherwise beginner-friendly language. Throughout my years of coding and teaching Python, I’ve watched countless talented programmers struggle with these exact issues, often spending hours debugging problems that could be avoided with proper understanding.

Mutable default arguments, class variable misuse, and closure late binding all stem from Python’s unique handling of object references and scope. Meanwhile, variable scope confusion, circular imports, and destructor misuse relate to Python’s execution model and memory management system. Though subtle, these errors can create frustrating bugs that appear seemingly random or inconsistent.

Thankfully, each of these errors has straightforward solutions once you understand the underlying mechanisms. Rather than fighting against Python’s design, embracing patterns like using None as a default argument placeholder, properly initializing instance variables in __init__, and leveraging context managers instead of destructors will significantly improve your code quality.

Python’s power comes from its simplicity and flexibility, yet these same qualities can mask important nuances in its behavior. By familiarizing yourself with these common pitfalls, you’ll write more robust code and spend less time hunting down mysterious bugs. Additionally, recognizing these patterns will deepen your understanding of Python’s core design philosophy.

Whether you’re building simple scripts or complex applications, awareness of these hidden errors will transform you from a casual Python user into a more effective developer. Take time to experiment with the examples provided, test the fixes, and apply these lessons to your own projects. Your future self will certainly thank you when you avoid these common traps that plague even experienced Python programmers.

Key Takeaways

Understanding these seven hidden Python errors will save you countless debugging hours and help you write more reliable code from the start.

Avoid mutable default arguments – Use None as default and initialize lists/dicts inside functions to prevent shared state across calls • Initialize instance variables in __init__ – Class variables are shared among all instances; use constructor for instance-specific data • Never modify lists while iterating – Create copies or iterate backwards to avoid skipping elements and unpredictable behavior • Use global keyword carefully – Python treats variables as local when assigned within functions, causing UnboundLocalError • Fix closure late binding with default arguments – Variables in closures reference current values, not values at definition time • Restructure circular imports – Move imports inside functions or reorganize modules to eliminate dependency loops

These errors often appear in production environments where they’re hardest to debug. By recognizing these patterns early and applying the recommended fixes, you’ll develop more robust Python applications and avoid the frustration of mysterious bugs that seem to appear randomly in your code.

FAQs

Q1. What are some common hidden errors in Python that beginners should be aware of? Some common hidden errors in Python include using mutable default arguments, incorrectly using class variables, modifying lists while iterating over them, confusing variable scope in functions, and misunderstanding late binding in closures. Being aware of these pitfalls can help beginners write more robust code.

Q2. How can I avoid issues with mutable default arguments in Python functions? To avoid issues with mutable default arguments, use None as the default value and initialize the mutable object inside the function. For example:

def add_item(name, item_list=None):
    if item_list is None:
        item_list = []
    item_list.append(name)
    return item_list

Q3. What’s the best way to handle circular imports in Python? The best way to handle circular imports is to restructure your code to eliminate mutual dependencies. If that’s not possible, you can move imports inside functions or use dynamic imports. For example:

def function_a():
    from module_b import function_b  # Import inside function
    function_b()

Q4. Why should I be cautious when using the del method in Python? The del method in Python is unpredictable and can cause hidden bugs. Python doesn’t guarantee when or if it will run, and exceptions in del are often ignored. Instead of relying on del, use context managers with enter and exit methods for resource management.

Q5. How can I properly handle variable scope in Python functions? To properly handle variable scope in Python functions, use the ‘global’ keyword when you need to modify a global variable inside a function. Alternatively, pass arguments and return values instead of relying on global variables. For example:

def update_counter(current_value):
    return current_value + 1

counter = 0
counter = update_counter(counter)