Debugging Gunicorn: "No module named 'django'" on a Working Virtual Environment

The Setup

I was deploying a Django app to my EC2 instance. Same stack I'd used before — Ubuntu, PostgreSQL, Gunicorn, Nginx. I'd already:

  • Created a virtual environment
  • Activated it
  • Installed Django, psycopg2, and all dependencies
  • Confirmed everything worked with python manage.py runserver

Time to test Gunicorn before wiring it to Nginx:

gunicorn docreview.wsgi:application --bind 0.0.0.0:8001

And immediately:

ModuleNotFoundError: No module named 'django'

That made no sense. Django was clearly installed — I'd just run migrations five minutes earlier.


First Instinct: Check the Virtual Environment

The most common cause of this error is a virtual environment that isn't actually active. So I checked:

which python3

It pointed inside my venv. Activated correctly. Confused, I checked what was actually installed:

pip list

Django was right there in the list. So the environment had Django. Gunicorn just couldn't see it.


Second Instinct: Check Which Gunicorn Was Running

This is the part I almost skipped, and it turned out to be the actual problem:

which gunicorn
/usr/bin/gunicorn

There it was. That's not my virtual environment — that's the system-wide gunicorn. Even though my venv was active in the terminal, the gunicorn command was resolving to a different installation entirely, one that had no idea my venv or my project's Django even existed.


Why This Happens

A virtual environment changes which python and pip your shell uses. But if a tool was installed system-wide before you created the venv — or installed in a separate step that didn't actually target the venv — your shell can still find that system version first, depending on your PATH.

In my case, I had run pip install gunicorn earlier, but for whatever reason it hadn't landed inside the venv that time. The fix was simple once I knew what to check:

pip install gunicorn
which gunicorn

The second time, it correctly pointed to:

/var/www/document-review-app/venv/bin/gunicorn

Round Two: Same Error, Different Cause

I ran gunicorn again, expecting it to work. Same error.

ModuleNotFoundError: No module named 'django'

This time the venv path was correct. So why was it still failing?

The fix that actually worked was running gunicorn through Python's module flag instead of calling it directly:

python3 -m gunicorn docreview.wsgi:application --bind 0.0.0.0:8001

This started cleanly with no errors.


Why -m Made the Difference

Calling a tool directly (gunicorn ...) relies on your shell finding the right executable on PATH and that executable's shebang line pointing to the right interpreter. Calling it as a module (python3 -m gunicorn ...) skips all of that — it tells the currently active Python interpreter to load and run the gunicorn module directly, using whatever environment that interpreter belongs to.

If there's ever ambiguity about which gunicorn or which python is being picked up, python3 -m <tool> removes the ambiguity completely. It's now my default way of running anything inside a virtual environment, not just gunicorn.


Round Three: "Address Already in Use"

After getting gunicorn running, I stopped it and made a config change, then tried to restart:

Error: Connection in use: ('0.0.0.0', 8001)

The earlier process hadn't actually been killed — it was still holding the port. The fix:

sudo lsof -ti:8001 | xargs sudo kill -9

lsof -ti:8001 finds the process ID using that port, and kill -9 forces it to stop. After that, gunicorn started cleanly.


What I'd Check First Next Time

If gunicorn (or any tool inside a venv) throws ModuleNotFoundError for a package you know is installed:

  1. Confirm the venv is active — check your terminal prompt for (venv)
  2. Check what's actually installed: pip list
  3. Check which binary is actually running: which gunicorn
  4. If it points outside your venv, that's the bug
  5. Reinstall inside the active venv if needed: pip install gunicorn
  6. Run via python3 -m <tool> instead of calling the tool directly — this sidesteps PATH issues entirely
  7. If the port is stuck, find and kill the old process: sudo lsof -ti:<port> | xargs sudo kill -9

The Takeaway

Nothing about this bug was related to Django, PostgreSQL, or my actual application code. It was entirely about which Python and which gunicorn my shell was resolving to — a reminder that in deployment, environment correctness matters just as much as code correctness. The app was never broken. The terminal was just running the wrong tool.