ModuleNotFoundError in production, but not on your laptop
Everyone's first instinct is to install the package again. In production that almost never helps, because the package is usually already there and Python is simply not looking where you assume it is looking.
CI is green. Tests pass. You deploy, and the worker will not start:
ModuleNotFoundError: No module named 'app.services'. The file is right there on the server. You can
see it. Python cannot.
How Python actually finds a module
Python does not search your project. It searches sys.path, a list built at interpreter startup from
the script's directory, the PYTHONPATH variable, and the site-packages of whichever interpreter is
running. Nothing outside that list exists, however obviously it sits on disk.
That reframes the problem, because the question is almost never whether the package is installed. It is which interpreter is running, and what is on its path. Locally you have a clear answer, since your shell activated a virtualenv when you opened the terminal. In production, systemd or Docker or a process manager started the thing, and nobody activated anything.
Six reasons, in order
1. The service is not using your virtualenv. The single most common cause. A systemd unit with
ExecStart=/usr/bin/gunicorn runs the system Python, not the one in /srv/app/.venv. It
works when you SSH in and run it by hand, because your shell activated the venv. Point the unit at the absolute
path inside the venv: /srv/app/.venv/bin/gunicorn.
2. The working directory is different. Python puts the script's directory on
sys.path, not your current directory. Start the app from / instead of the project root
and your own top-level package stops resolving. WorkingDirectory= in systemd, WORKDIR in
a Dockerfile.
3. It was installed for a different user. pip install --user as your login
account puts packages in that account's home. The service runs as www-data, which has a different
home and never sees them. Install into the venv, or install system-wide, but pick one.
4. A name collision. A file called logging.py, email.py,
types.py or queue.py in your project root shadows the standard library module of the same
name. The symptom is often an import error somewhere else entirely, in a dependency that expected the real one.
Rename the file.
5. Case. You develop on macOS or Windows, where the filesystem does not care. Production is
Linux, where it does. from app.Services import x resolves locally and fails on the server. Trivial,
infuriating, and it only ever bites after deploy.
6. The editable install is stale. pip install -e . writes a path file pointing at
your source tree. Move or rebuild that tree, deploy without reinstalling, and the pointer dangles. Common in
Docker builds that copy the project in after running pip.
The thirty-second diagnostic
Do not reason about this. Ask the process that is failing, with the same interpreter and the same environment:
import sys
print("exe:", sys.executable)
print("cwd:", __import__("os").getcwd())
print("path:")
for p in sys.path:
print(" ", p)
Put that at the very top of your entry point, deploy, read the output, take it back out. Three lines of truth
beat twenty minutes of theory. sys.executable alone resolves reasons one and three; adding the
working directory and the path covers the rest.
If the interpreter is correct and the path looks right, confirm what it can actually see:
/srv/app/.venv/bin/python -c "import app.services; print(app.services.__file__)"
Run it as the service user, not as yourself. sudo -u www-data ... has exposed more of these than any
other single command.
Making it not happen again
The underlying problem is that your laptop and your server disagree about what "running the app" means. Close that gap and this class of bug mostly disappears.
Always use absolute paths into the venv in service definitions, never a bare command name. Set
WorkingDirectory explicitly rather than relying on whatever the process manager inherits. Pin
dependencies with a lock file so staging and production resolve identically. And if you deploy in containers,
build and run the same image everywhere, which is most of the value containers were supposed to provide.
An import error is the cheapest possible production failure to fix and one of the most expensive to notice, because the app never starts and therefore never tells anyone.
That is the asymmetry worth engineering against. A health check that fails loudly beats a worker that quietly refuses to boot, and something watching the process output beats finding out from a customer. The Python connector takes about two minutes to install and reads the traceback as it is written. That is the part you cannot get from the app itself, because the app is the thing that did not start.
Found something wrong in here? Tell me.