Lab notes · N°03 · · 3 min read
Fixing ModuleNotFoundError When a Python Cron Job Works From the Shell
Same script, same machine: works over ssh, ModuleNotFoundError from cron. Cron never activates your venv - point the entry at the venv's own python.
- Written by
- Kamol Nazarov
- Published
- Tags
- python · cron · venv
- Follow
- RSS feed
The note
The nightly job pulls the day's repair orders into a spreadsheet for the front desk. Tested it over ssh before bed: clean run, rows in the sheet. At 2:30 the next morning cron emailed me this instead:
Traceback (most recent call last):
File "/home/shop/order-sync/sync.py", line 3, in <module>
import requests
ModuleNotFoundError: No module named 'requests'
If you're searching "python cron job module not found" or "python script works in terminal but not cron", this is that situation exactly: same machine, same script, same user � but only cron can't find the modules. There's really one cause, one fix, and two neighboring traps that ride along with it.
What cron actually runs
Cron is not your login shell. It launches each entry with a nearly empty environment: SHELL=/bin/sh, a minimal PATH (typically /usr/bin:/bin), your HOME, and that's about it. It never reads .bashrc, .profile, or anything you've exported by hand.
Two consequences do the damage together:
- Whatever you installed into a virtualenv is invisible, because nothing ever ran
source venv/bin/activate. - The bare
python3in your crontab resolves through that minimal PATH to the system interpreter, whose site-packages don't contain your packages.
System Python plus missing venv equals ModuleNotFoundError, every time.
Prove it in one line
Before fixing anything, confirm which interpreter cron is really using. Drop this at the top of the script temporarily:
import sys
print(sys.executable)
print("\n".join(sys.path))
Cron mails command output to you (or your entry redirects to a log � it should). You'll see /usr/bin/python3 where you expected /home/shop/order-sync/venv/bin/python3. That mismatch is the entire bug.
The fix nobody tells you: skip activation entirely
Most threads tell you to source activate in a wrapper script. You can � but activation is optional. A venv is just a directory: a copy of the interpreter and its own site-packages, with a pyvenv.cfg beside the binary. The interpreter locates its venv from its own path, not from environment variables. Run the venv's python directly and you get the venv, no activation, no PATH setup, from any shell, service, or container.
So this is how to run a python venv in crontab without the ceremony � point the entry at the venv's own interpreter:
30 2 * * * /home/shop/order-sync/venv/bin/python3 /home/shop/order-sync/sync.py >> /home/shop/order-sync/cron.log 2>&1
One line, absolute paths, done. That version survives a server rebuild and a migration to systemd later, because it depends on nothing outside the entry itself.
The two traps that ride along
The wrong interpreter explains the import error, but a bare crontab entry tends to collect two more failures on the same night:
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError |
System interpreter ran instead of the venv | Full path to venv/bin/python3 |
FileNotFoundError on a config or data file |
Cron starts in $HOME, not your project dir; relative paths break |
cd /project && before the command, or absolute paths |
sh: pg_dump: command not found from a subprocess call |
Minimal PATH misses /usr/local or custom bins |
PATH= line at the top of the crontab |
The hardened version handles all three:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
30 2 * * * cd /home/shop/order-sync && ./venv/bin/python3 sync.py >> cron.log 2>&1
The PATH= and SHELL= lines apply to every entry below them in the same crontab. The cd ... && guard is what keeps open("orders.csv") resolving to the file you meant.
Test it the way cron will run it
You can reproduce cron's empty environment without waiting for 2:30 AM:
env -i HOME="$HOME" SHELL=/bin/sh /bin/sh -c \
'/home/shop/order-sync/venv/bin/python3 /home/shop/order-sync/sync.py'
env -i wipes your shell's variables, so the command sees exactly the stripped world cron provides � same PATH emptiness, same working directory rules. If it works there, it works in the crontab; if it doesn't, the error you get is the real one, not one polluted by your interactive session.
One sentence to keep: cron never activates anything � it runs /bin/sh with a scrubbed environment, so point the entry at the venv's own interpreter and use absolute paths. After that, "crontab python no module named" stops being a mystery and your 2:30 AM email goes back to being a receipt.