Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 080 / 100

Common Python errors and fixes: best practices explained

Errors every developer hits — and how to fix them.

Expona Admin7 min read

Common Python errors and fixes: best practices explained. Errors every developer hits — and how to fix them.

After 80 days of building Python backends, you've probably hit errors that seemed cryptic at first but made sense once diagnosed. We're going to walk through four errors that appear repeatedly across production systems and show you exactly how to fix them—and more importantly, how to avoid them in the future.

ModuleNotFoundError: wrong venv or path

This is often the first error new developers encounter, but even experienced teams hit it when onboarding or switching between projects. The error means Python can't find a module you're trying to import, usually because your virtual environment isn't activated or your Python path doesn't include the module location.

The scenario: you've just cloned a repository, installed dependencies, and try to run the application. You get ModuleNotFoundError: No module named 'fastapi' even though it's in requirements.txt. Nine times out of ten, you're running Python from the system interpreter instead of the venv.

python
# Wrong approach - using system Python
$ python main.py
ModuleNotFoundError: No module named 'fastapi'

# Correct approach - activate venv first
$ source venv/bin/activate  # macOS/Linux
$ venv\Scripts\activate     # Windows
$ python main.py            # Now works

But there's a deeper issue: your IDE or editor might be configured to use the wrong interpreter. We've seen this happen when switching between projects—PyCharm, VS Code, or your editor still points to the previous project's venv or the system Python.

Check your interpreter path explicitly. In a script, you can verify which Python is running:

python
import sys
print(sys.executable)  # Should show your venv path
print(sys.path)        # Shows all search paths

If you're building a package or working with relative imports, verify your package structure includes __init__.py files in directories you want to import from. A missing __init__.py can silently break imports across your codebase.

RecursionError: missing base case

Recursion is elegant when it works and catastrophic when it doesn't. A RecursionError happens when a function keeps calling itself without a condition to stop, hitting Python's recursion limit (usually around 1,000 calls by default).

Let's say you're building a tree traversal for an organizational hierarchy. You write recursive code to walk up the chain to find the root manager:

python
# Wrong - missing base case
def find_root_manager(employee_id):
    employee = db.get_employee(employee_id)
    return find_root_manager(employee.manager_id)  # No stop condition!

# Correct - with base case
def find_root_manager(employee_id):
    employee = db.get_employee(employee_id)
    if employee.manager_id is None:  # Base case
        return employee
    return find_root_manager(employee.manager_id)

The base case is your escape hatch. Without it, recursion never stops. In the example above, if you have circular manager references or a data issue, you'd hit RecursionError quickly.

There's a second, subtler issue: Python's recursion limit is shallow compared to other languages. If you're processing deeply nested structures (like a category tree 500 levels deep), you'll hit the limit. In those cases, convert to iteration or increase the limit carefully:

python
import sys
sys.setrecursionlimit(5000)  # Be cautious—can cause stack overflow

Prefer iteration for performance-critical code. Recursion is cleaner conceptually but costs more in memory and CPU per call. In a request handler processing thousands of items, that overhead adds up.

UnboundLocalError: assigning a global inside a function

This error catches developers off guard because the code looks correct at first glance. You're reading a global variable, modifying it, and Python interprets that as creating a local variable—but you're trying to read it before assignment, causing UnboundLocalError.

A realistic scenario: you have a shared counter or cache dictionary at the module level, and a function tries to update it:

python
counter = 0

def increment_counter():
    counter += 1  # UnboundLocalError: local variable 'counter' referenced before assignment
    return counter

increment_counter()

Python sees counter += 1 and thinks "this function creates a local counter." But the right-hand side of += reads counter before it's been assigned locally, hence the error.

The fix is to declare the variable as global:

python
counter = 0

def increment_counter():
    global counter
    counter += 1
    return counter

increment_counter()  # Works

However, we rarely recommend global state in production code. It's error-prone and hard to test. A better approach is to use a class or pass the value as an argument:

python
# Better approach with a class
class Counter:
    def __init__(self):
        self.value = 0
    
    def increment(self):
        self.value += 1
        return self.value

counter = Counter()
counter.increment()  # Clean, testable, no global state

If you're working with mutable objects like dictionaries or lists, you can modify them without the global keyword because you're not reassigning the variable itself—just mutating its contents:

python
cache = {}

def set_cache(key, value):
    cache[key] = value  # No global needed—we're mutating the dict, not reassigning it

set_cache('user_123', {'name': 'Alice'})  # Works fine

Event loop is closed: mixing sync and async incorrectly

As you move into async programming with frameworks like FastAPI or aiohttp, this error becomes common. It typically means you're trying to run an event loop when one is already closed, often by mixing synchronous and asynchronous code carelessly.

A typical scenario: you're using an async library (like an async database driver) but accidentally running it in a sync context, or you're trying to run a second event loop in the same thread:

python
import asyncio

async def fetch_data():
    await asyncio.sleep(1)
    return "data"

# Wrong - trying to run event loop multiple times
asyncio.run(fetch_data())
asyncio.run(fetch_data())  # RuntimeError: Event loop is closed

Each call to asyncio.run() creates and closes an event loop. If you need to run multiple async functions, structure them together:

python
# Correct - single event loop for multiple operations
async def main():
    result1 = await fetch_data()
    result2 = await fetch_data()
    return result1, result2

asyncio.run(main())

Another common mistake: calling async functions from sync code without proper handling. If you're in a FastAPI endpoint, you're already inside an async context, so you can await directly. But if you're in a CLI script or a Celery task (which is sync), you need to create an event loop:

python
# In FastAPI (async context already exists)
@app.get("/data")
async def get_data():
    data = await fetch_data()  # Works - we're in async context
    return data

# In a sync CLI script
if __name__ == "__main__":
    data = asyncio.run(fetch_data())  # Correct way to bridge sync to async
    print(data)

If you're debugging this in an IPython or Jupyter notebook, you might hit event loop issues because notebooks have their own event loop. Use nest_asyncio as a workaround for development:

python
import nest_asyncio
nest_asyncio.apply()  # Allows nested event loops in notebooks

In production, never mix sync and async carelessly. If your database driver is async, use it consistently. If you're calling a sync library from async code, use asyncio.to_thread() to avoid blocking the event loop:

python
import asyncio

def blocking_operation():
    # Simulates a sync library call
    return "result"

async def main():
    # Run blocking operation in a thread pool
    result = await asyncio.to_thread(blocking_operation)
    return result

asyncio.run(main())

Common mistakes

  • Forgetting to activate your venv before running code or installing packages. This leads to ModuleNotFoundError and version conflicts across your team.
  • Writing recursive functions without testing with edge cases. A missing base case works fine until production data triggers infinite recursion.
  • Using global state instead of proper dependency injection or classes. It works at small scale but becomes a debugging nightmare in larger systems.
  • Assuming async/await works everywhere. Mixing sync and async contexts without understanding event loops causes hard-to-diagnose bugs in production.
  • Not checking Python version and library compatibility. A module import works on your machine but fails on the server running a different Python version.

Key takeaways

  • Always verify you're using the correct venv and Python interpreter—check sys.executable if unsure.
  • Every recursive function needs a clear base case and should handle edge cases like circular references or deep nesting.
  • Avoid global state; use classes, dependency injection, or function arguments to pass values cleanly and make code testable.
  • Understand async/await and event loops before mixing sync and async code—use asyncio.run() to bridge from sync to async, and asyncio.to_thread() to call sync code from async.
  • Common Python errors and fixes follow patterns—recognizing them early saves debugging time across development, testing, and production environments.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles