A Python script that runs perfectly in your shell and silently does nothing under cron is almost always an environment problem: cron gives you a minimal PATH, no virtualenv, and a different working directory.
Cron is the oldest and most reliable scheduler on any Unix system, and it is still the right tool for a great many jobs. The difficulty is that it runs your script in an environment that shares very little with your interactive shell.
That difference accounts for nearly every “it works when I run it manually” report. Fix the environment assumptions and cron becomes boring again, which is exactly what you want from a scheduler.
Table of contents
- The setup that works
- Why your job silently does nothing
- Handling environment variables and secrets
- Logging, so failures are visible
- Overlapping runs and the lock you need
- When to use systemd timers instead
- How this fits the rest of the stack
- FAQ
The setup that works
Absolute paths everywhere, and output captured to a file. Both parts are load-bearing.
crontab -e
# Every day at 03:00 -- venv interpreter, absolute script path, output captured
0 3 * * * /opt/myapp/.venv/bin/python /opt/myapp/scripts/daily_report.py >> /var/log/myapp/cron.log 2>&1
# Every 15 minutes
*/15 * * * * /opt/myapp/.venv/bin/python /opt/myapp/scripts/poll.py >> /var/log/myapp/poll.log 2>&1
# Weekdays at 09:00, from a specific working directory
0 9 * * 1-5 cd /opt/myapp && /opt/myapp/.venv/bin/python -m myapp.jobs.morning >> /var/log/myapp/morning.log 2>&1
The five fields are minute, hour, day of month, month, and day of week. crontab.guru is worth bookmarking for anything non-obvious.
Note there is no source .venv/bin/activate anywhere. Activation is a shell convenience that sets PATH; calling the venv’s interpreter directly achieves the same result and works in any context. Never activate a virtualenv in a crontab.
Why your job silently does nothing
Four causes account for nearly all of these, and they are all environment differences.
- PATH is minimal. Cron typically provides only
/usr/bin:/bin. A barepythonmay not resolve at all, and if it does it is the system interpreter, not your venv. - No virtualenv. Your shell has one active; cron does not. The script starts, fails on the first third-party import, and exits.
- Different working directory. Cron starts in the user’s home directory, so every relative path in your script points somewhere unexpected.
- No environment variables. Anything loaded by your shell profile — API keys, database URLs, configuration — is absent. Cron does not read
.bashrcor.profile.
The tell for all four is the same: nothing in the log, or a traceback about a missing module or file. Reproduce it directly rather than guessing, by stripping the environment the way cron does.
# Run with an empty environment to reproduce cron's conditions
env -i /opt/myapp/.venv/bin/python /opt/myapp/scripts/daily_report.py
# See exactly what cron gives you: add this line temporarily
# * * * * * env > /tmp/cron-env.txt
That env > /tmp/cron-env.txt trick is the fastest way to end the debate about what cron actually provides. Add it, wait a minute, read the file, remove it.
Handling environment variables and secrets
Since cron reads no shell profile, configuration has to be supplied deliberately. Three approaches, in ascending order of robustness.
# 1. Set variables at the top of the crontab itself
PATH=/usr/local/bin:/usr/bin:/bin
DATABASE_URL=postgresql://app:secret@localhost/app
[email protected]
0 3 * * * /opt/myapp/.venv/bin/python /opt/myapp/scripts/report.py >> /var/log/myapp/cron.log 2>&1
That works but puts credentials in the crontab, readable by anyone who can list it. Better to load them from a file with proper permissions.
0 3 * * * set -a && . /etc/myapp/env && set +a && /opt/myapp/.venv/bin/python /opt/myapp/scripts/report.py >> /var/log/myapp/cron.log 2>&1
Best is to have the script load its own configuration, so it behaves identically under cron, in a container, and when you run it by hand.
# At the top of the script
from pathlib import Path
from dotenv import load_dotenv
load_dotenv("/etc/myapp/env")
# Do not depend on the working directory
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
That Path(__file__).resolve().parent pattern removes the working-directory problem permanently. A script that computes its own paths does not care where it was launched from.
Logging, so failures are visible
Cron mails output to the user by default, which on most servers means it goes nowhere. Redirect explicitly and log properly inside the script.
import logging
import sys
from logging.handlers import RotatingFileHandler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
handlers=[
RotatingFileHandler(
"/var/log/myapp/report.log",
maxBytes=10_000_000,
backupCount=5,
),
logging.StreamHandler(sys.stderr),
],
)
log = logging.getLogger("daily_report")
def main():
log.info("starting daily report")
try:
run_report()
except Exception:
log.exception("report failed")
return 1
log.info("report complete")
return 0
if __name__ == "__main__":
sys.exit(main())
log.exception inside the handler records the full traceback, which is the difference between knowing something failed and knowing why. Returning a non-zero exit code matters too — it is what monitoring can act on.
Use RotatingFileHandler rather than plain file logging. A job running every fifteen minutes will fill a disk eventually, and a full disk takes down more than the cron job.
The 2>&1 in the crontab line is not redundant alongside this. It catches output from anything that writes outside your logging setup — an unhandled exception before logging is configured, or a C extension writing to stderr directly.
Overlapping runs and the lock you need
Cron starts your job on schedule regardless of whether the previous run finished. A job scheduled every five minutes that occasionally takes seven will eventually have several copies running at once, competing over the same data.
# flock: skip this run if the previous one is still going
*/5 * * * * /usr/bin/flock -n /tmp/poll.lock /opt/myapp/.venv/bin/python /opt/myapp/scripts/poll.py >> /var/log/myapp/poll.log 2>&1
flock -n acquires the lock or exits immediately. One line, no code changes, and it eliminates an entire class of data-corruption bug. Add it to any job that could conceivably overrun its interval.
For the same guarantee inside the script — useful when it also runs from other contexts — take the lock in Python.
import fcntl
import sys
lockfile = open("/tmp/poll.lock", "w")
try:
fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("already running, exiting", file=sys.stderr)
sys.exit(0)
When to use systemd timers instead
On any modern Linux distribution, systemd timers are the better-engineered option and worth considering for new work.
# /etc/systemd/system/daily-report.service
[Unit]
Description=Daily report
[Service]
Type=oneshot
User=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/opt/myapp/.venv/bin/python /opt/myapp/scripts/daily_report.py
# /etc/systemd/system/daily-report.timer
[Unit]
Description=Run the daily report at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now daily-report.timer
systemctl list-timers daily-report.timer
journalctl -u daily-report.service --since today
The advantages are concrete: logs go to the journal automatically, EnvironmentFile solves configuration cleanly, Persistent=true runs a missed job after downtime, systemd will not start a second instance while one is running, and RandomizedDelaySec staggers jobs so a fleet does not stampede at exactly 03:00.
The cost is two files instead of one line. For a single simple job cron is still fine. For anything you depend on, the timer is the better tool.
How this fits the rest of the stack
Scheduled jobs are where reliability quietly erodes: they run on one machine, log to a file nobody reads, and fail silently until someone notices missing data. Whether it runs at all becomes a property of a server rather than of your application. RunxBuild runs scheduled work alongside the services it belongs to, with output in the same logs as everything else, and the RunxBuild hosting calculator shows what the worker costs next to the API and the database rather than hiding it inside a VPS bill.
Useful related references:
- Python Not Equal: != vs is not, and Why the Difference Bites
- Python Integer Division: Why // Floors and Why -7 // 2 Is -4
- Python for Websites: Where It Fits and Where It Does Not
- Python services on RunxBuild
FAQ
Why does my Python script work manually but not in cron?
Cron provides a minimal PATH, no virtualenv, no shell profile variables, and starts in your home directory. Use the absolute path to the venv interpreter, absolute script paths, and load configuration explicitly.
How do I use a virtualenv in a cron job?
Call the interpreter inside it directly: /opt/myapp/.venv/bin/python script.py. Do not source the activate script — activation only manipulates PATH, and calling the interpreter achieves the same thing reliably.
Where does cron output go?
By default it is mailed to the user, which on most servers means it is discarded. Always redirect explicitly with >> /var/log/myapp/cron.log 2>&1 and configure logging inside the script as well.
How do I stop cron jobs overlapping?
Wrap the command in flock -n /tmp/job.lock, which skips the run if the previous one is still going. One line in the crontab, no code changes, and it prevents a whole class of concurrency bug.
Should I use cron or systemd timers?
Timers are better for anything you depend on: journal logging, EnvironmentFile support, missed-run catchup with Persistent=true, no overlapping instances, and randomised delays. Cron remains fine for a single simple job.