Python Script Cron Job: Virtualenvs, Paths, and Logging
Contents
To run a Python script from cron, call the interpreter inside the project's virtual environment by its absolute path, start in the directory your imports expect, and send the output to a log file: 15 2 * * * cd /srv/rates && /srv/rates/.venv/bin/python -u -m rates.fetch >> /var/log/rates/fetch.log 2>&1. Most "works in my terminal, fails in cron" problems come from three differences: cron finds a different python3 on its short PATH, starts the job in your home directory, and passes none of the variables your shell profile sets. A reliable job also exits nonzero when it fails, logs with timestamps, can't hang forever, and reports each run to something that notices when one doesn't happen.
Versions checked October 25, 2026: Python 3.15.1 (3.15.0 was released October 9, 2026), uv 0.13.5, pipx 1.18.4. Cron environment variables covers what cronie, Debian's cron, and BusyBox crond each pass to a job. If your schedule lives inside a long-running Python process, see APScheduler monitoring; for Django management commands and Celery beat, see Django cron monitoring.
Call the Interpreter You Tested With#
When a crontab line says python3, the shell looks it up on cron's PATH (/usr/bin:/bin for user crontabs on Debian; on Ubuntu 24.04 and later, the daemon's PATH, which adds /usr/local/bin and the sbin directories) and finds the system interpreter. Your terminal may have found a pyenv shim, a Homebrew or conda Python, or an activated virtualenv first. The system interpreter has none of your project's packages, so the job dies with ModuleNotFoundError. A script starting with #!/usr/bin/env python3 resolves the same way. A bare python may not exist: on Debian, /usr/bin/python comes from the separate python-is-python3 package, and without it the job fails with python: not found and exit status 127.
To see what cron actually runs, add a temporary entry, wait a minute, read the file, and remove the entry:
* * * * * python3 -c 'import os, sys; print(sys.executable, os.getcwd(), *sys.path, sep="\n")' > /tmp/cron-python.txt 2>&1Packages installed with pip install --user live in the account's user site directory, ~/.local/lib/pythonX.Y/site-packages, so a job moved to root's crontab or to /etc/cron.d under another account stops seeing them.
Use the Virtualenv's Python Instead of Activating It#
The venv documentation says you don't need to activate an environment if you call its interpreter by full path, and scripts installed into it run without activation because their shebang points at that interpreter. Activation only prepends the environment's bin directory to PATH. Both of these use their environment's packages:
15 2 * * * cd /srv/rates && /srv/rates/.venv/bin/python -m rates.fetch
30 3 * * * cd /srv/analytics && /srv/analytics/.venv/bin/dbt runThe second runs a console script, a command a package installs into .venv/bin. Because those shebangs hold absolute paths, a moved or renamed environment breaks them; the venv docs say to recreate an environment rather than move it.
Using uv: Sync at Deploy Time, Not at Run Time#
By default, uv run locks and syncs the project before running the command (locking and syncing), and it includes the dev dependency group unless told otherwise (default groups). In a cron job that can mean resolving dependencies, rewriting uv.lock, or downloading packages at 02:15. The sturdier pattern is uv sync --locked --no-dev when you deploy, then pointing cron at /srv/rates/.venv/bin/python, which needs no uv at all.
If you want uv run in the crontab anyway:
15 2 * * * /home/deploy/.local/bin/uv run --directory /srv/rates --no-sync python -u -m rates.fetch- Use uv's absolute path. The standalone installer puts
uvin$XDG_BIN_HOME,$XDG_DATA_HOME/../bin, or~/.local/bin(storage reference), none of which is on cron'sPATH.command -v uvin your shell prints it. - Use
--directory, not--project.--projectonly tells uv where to findpyproject.tomland.venv; relative paths still resolve against the current directory, which under cron is your home.--directorychanges into the project first (uv CLI source). - Pick a sync mode.
--no-syncskips the environment check and implies--frozen, which usesuv.lockwithout checking it;--lockedmakes the run fail if the lockfile is out of date (run options).
A PEP 723 script with the shebang #!/usr/bin/env -S uv run --script (uv scripts guide) fails the same way, because env can't find uv. Run it as /home/deploy/.local/bin/uv run --script /srv/jobs/report.py, after locking it with uv lock --script.
Tools Installed With pipx#
pipx install gives each application its own environment and links its commands into ~/.local/bin, or /usr/local/bin with --global (pipx paths). Neither directory is on the default cron PATH of cronie or Debian's cron, so call the link by full path, such as /home/deploy/.local/bin/borgmatic.
Script Path or -m: Where Imports Come From#
For python /srv/rates/rates/fetch.py, Python puts the script's directory first on sys.path. For python -m rates.fetch, it puts the current working directory there (sys.path). So -m works from the project root in your terminal and fails under cron, which starts in $HOME, with No module named 'rates':
| Crontab command | First entry on sys.path |
from rates import sources works when |
|---|---|---|
/srv/rates/.venv/bin/python /srv/rates/rates/fetch.py |
/srv/rates/rates |
the project is installed in the venv |
/srv/rates/.venv/bin/python -m rates.fetch |
$HOME |
the project is installed in the venv |
cd /srv/rates && /srv/rates/.venv/bin/python -m rates.fetch |
/srv/rates |
always |
"Installed" means pip install -e ., or uv sync for a project that defines a build system. The working directory also decides relative file paths: under cron, open("config.toml") looks in your home directory. Build paths from the module, Path(__file__).resolve().parent / "config.toml", or cd first.
Give the Script Its Environment#
- Settings and secrets. Read required values with
os.environ["RATES_URL"], which raisesKeyErrorand fails the run;os.environ.get()with a default hides the omission. Load them from a file only the job's account can read, through a shell wrapper,uv run --env-file, or python-dotenv with an explicit path. python-dotenv 1.2.4 returnsFalserather than raising when the file is missing, so theos.environ[...]read is what catches it. BusyBox crond ignores crontabNAME=valuelines. - Unbuffered output. Under cron, stdout is a file or pipe, so Python block-buffers it; stderr is line-buffered since Python 3.9 (sys.stdout).
print()output shows up late, out of order with log lines, or not at all if the process is killed.-ufixes it everywhere;PYTHONUNBUFFERED=1does the same, except as a crontab line on BusyBox. - Locale. Since Python 3.7, a job that gets no locale variables still uses UTF-8, because Python coerces the C locale or turns on UTF-8 mode (PEP 538, PEP 540). Python 3.15 uses UTF-8 by default whatever the locale (What's New in 3.15). On 3.14 and earlier,
open()without anencodingstill follows a non-UTF-8 locale if cron passes one, so writeencoding="utf-8". PYTHONPATH. Exporting it in your profile is a common reason imports work in a terminal and not in cron. Copying it into the crontab hides the problem, and every Python process the job starts inherits it. Install the project into its environment or use-mfrom the project directory instead.
Exit Nonzero on Failure and Log With Timestamps#
An uncaught exception prints its traceback to stderr (sys.excepthook), and the interpreter exits with status 1 (Py_RunMain). Cron doesn't act on the status, but everything around the job does: timeout, flock, a shell wrapper, a monitor.
- Put the work in a
main()that returns an int and end withsys.exit(main()).Nonecounts as 0;sys.exit("text")prints the text to stderr and exits 1 (sys.exit). - Return 1 when the job did only part of its work, such as 3 of 40 uploads failing.
- Never end an
exceptblock with a log line alone; that turns every failure into exit 0. If you catch to log, re-raise:
try:
partial.replace(target)
except OSError:
log.exception("could not replace %s", target)
raiselog.exception logs at ERROR with the traceback, and the bare raise keeps the original exception and the exit status.
Logging to stderr or a File#
Use logging rather than print(). logging.basicConfig() adds a StreamHandler, which writes to sys.stderr; include %(asctime)s in the format and every line carries a timestamp. With >> file 2>&1 on the crontab line, the file gets your log lines and any traceback.
If the script writes its own log file, use WatchedFileHandler. It reopens the file when its device or inode changes, which happens when logrotate moves the old file, its behavior unless copytruncate is set (logrotate(8)). An uncaught exception's traceback still goes to stderr, not to the handler, so redirect stderr too.
Put Timeouts Inside the Script and Around It#
Cron has no maximum runtime, and a Python job most often hangs on a call that waits forever:
- requests has no default timeout (docs); pass
timeout=(3.05, 30)for connect and read. The read timeout counts the wait between bytes, not the total. - httpx defaults to 5 seconds of network inactivity, and
timeout=Noneturns that off (docs). - urllib uses the socket module's default, which is no timeout (socket); pass
timeout=tourlopen. - subprocess.run(..., timeout=600) kills the child and raises
TimeoutExpired(subprocess).
Then wrap the whole run in timeout and flock on the crontab line, as the complete example below does. Cron job timeout covers timeout's exit codes and BusyBox's version, and prevent overlapping cron jobs covers flock and multi-host locks.
Turn SIGTERM Into a Clean Exit#
Python's default handlers cover SIGPIPE, which it ignores, and SIGINT, which raises KeyboardInterrupt (signal). SIGTERM keeps the system default, so a SIGTERM from timeout ends the interpreter on the spot: finally blocks and with exits don't run, buffered output is lost, and atexit functions are skipped. Convert it into SystemExit:
def stop_on_sigterm(signum, frame):
signal.signal(signal.SIGTERM, signal.SIG_IGN) # timeout may send it twice
sys.exit(128 + signum)GNU timeout signals the command and then its whole process group (timeout.c), so Python can receive SIGTERM twice; ignoring the second lets cleanup finish. The handler runs in the main thread between bytecode instructions, so a long computation inside C code delays it (execution of signal handlers). Nothing can catch SIGKILL, which timeout -k sends if the job is still running after the grace interval.
A Complete Example#
A job that downloads a daily exchange-rate CSV, with the project at /srv/rates and its environment at /srv/rates/.venv. The crontab assumes the server clock is on UTC:
15 2 * * * cd /srv/rates && flock -n /var/lib/rates/fetch.lock timeout -k 1m 20m /srv/rates/.venv/bin/python -u -m rates.fetch >> /var/log/rates/fetch.log 2>&1cd /srv/ratesmakes-m rates.fetchimportable without installing the project.flock -nskips the run if the previous one still holds the lock.timeout -k 1m 20msends SIGTERM at 20 minutes and SIGKILL a minute later.-ukeeps output unbuffered;>> ... 2>&1sends stdout, stderr, and tracebacks to one file the job's account can write.
The script, using python-dotenv for its settings file:
# /srv/rates/rates/fetch.py
"""Download the daily exchange-rate CSV. Run from /srv/rates: python -m rates.fetch"""
import logging
import os
import signal
import sys
import time
import urllib.request
from pathlib import Path
from dotenv import load_dotenv
log = logging.getLogger("rates.fetch")
def stop_on_sigterm(signum, frame):
signal.signal(signal.SIGTERM, signal.SIG_IGN) # timeout may send it twice
sys.exit(128 + signum)
def main() -> int:
source = os.environ["RATES_URL"] # KeyError, and exit 1, if unset
target = Path(os.environ["RATES_FILE"])
with urllib.request.urlopen(source, timeout=30) as response:
body = response.read()
rows = len(body.decode("utf-8").splitlines()) - 1 # minus the header line
if rows < 1:
log.error("%s returned no rates; keeping the previous file", source)
return 1
partial = target.with_name(target.name + ".partial")
partial.write_bytes(body)
partial.replace(target) # atomic: readers see the old file or the new one
log.info("wrote %d rates to %s", rows, target)
return 0
if __name__ == "__main__":
load_dotenv("/etc/rates/fetch.env") # mode 600, owned by the job's account
logging.Formatter.converter = time.gmtime # UTC timestamps
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
)
signal.signal(signal.SIGTERM, stop_on_sigterm)
sys.exit(main())An HTTP error, a response that isn't UTF-8, or a missing setting raises, which writes a traceback into the log file and exits 1. A response with no data rows exits 1 with a log line and leaves yesterday's file in place. Path.replace calls os.replace, an atomic rename on one filesystem, so a reader never sees half a file.
Report Every Run to a Monitor#
A nonzero exit helps only if something reads it, and a job that never starts produces nothing. A dead man's switch monitor expects each run on schedule and alerts on a reported failure or on silence.
There is no installable CronRadar Python SDK today and no auto-discovery for crontab; the Python integration page lists Python SDK connections as temporarily unavailable (checked September 20, 2026). Each job reports with its own HTTP check-in. This standard-library helper sends start when the block begins, then complete or fail, with a five-second timeout, and logs any monitoring error instead of raising it:
# /srv/rates/rates/cronradar.py
"""Report a job's start and result to CronRadar. Never raises into the job."""
import base64
import json
import logging
import os
import urllib.parse
import urllib.request
import uuid
log = logging.getLogger(__name__)
def _exit_code(exc):
"""The status a SystemExit produces: None means 0, a non-integer means 1."""
if exc.code is None:
return 0
return exc.code if isinstance(exc.code, int) else 1
class CheckIn:
def __init__(self, monitor_key, schedule, grace_period=None):
key = urllib.parse.quote(monitor_key, safe="")
self.url = f"https://cron.life/api/v1/ping/{key}"
self.params = {"schedule": schedule, "run_id": uuid.uuid4().hex}
if grace_period is not None:
self.params["gracePeriod"] = grace_period
self.outcomes = {} # optional: up to five numbers or booleans
def __enter__(self):
self._send("start")
return self
def __exit__(self, exc_type, exc, tb):
code = _exit_code(exc) if isinstance(exc, SystemExit) else None
if exc is None or code == 0:
self._send("complete", outcomes=self.outcomes)
elif code is not None:
self._send("fail", message=f"exited with code {code}")
else:
self._send("fail", message=type(exc).__name__)
return False # the exception or exit status passes through unchanged
def _send(self, event, outcomes=None, message=None):
try:
params = dict(self.params)
if message:
params["message"] = message
body = json.dumps({"outcomes": outcomes}).encode() if outcomes else b""
request = urllib.request.Request(
f"{self.url}/{event}?{urllib.parse.urlencode(params)}",
data=body,
method="POST",
)
if outcomes:
request.add_header("Content-Type", "application/json")
api_key = os.environ["CRONRADAR_API_KEY"]
token = base64.b64encode(f"{api_key}:".encode()).decode()
# Unlike add_header(), this header is not copied to a redirect.
request.add_unredirected_header("Authorization", f"Basic {token}")
with urllib.request.urlopen(request, timeout=5):
pass
except Exception as error:
# A monitoring problem must not fail the job or change its result.
log.warning("CronRadar %s not recorded (%s)", event, type(error).__name__)In fetch.py, add from rates.cronradar import CheckIn to the imports, put CRONRADAR_API_KEY in /etc/rates/fetch.env, and wrap the last line:
signal.signal(signal.SIGTERM, stop_on_sigterm)
with CheckIn("fetch-rates", "15 2 * * *", grace_period=300):
sys.exit(main())What each ending sends:
| How the run ends | Check-in | Exit status |
|---|---|---|
main() returns 0 |
complete |
0 |
main() returns 1 |
fail, "exited with code 1" |
1 |
| Uncaught exception | fail with the exception's type |
1 |
SIGTERM from timeout |
fail, "exited with code 143" |
124, from timeout |
| SIGKILL, out-of-memory kill, power loss | none after start |
varies |
A fail alerts immediately. The failure message is the exception's class name, never its text, which can contain data. If 02:15 passes with no start, the monitor shows warning, then goes critical and alerts once the grace period passes. A killed run leaves a start with no finish; CronRadar flags that as stuck only if the job's next expected time plus grace then passes with no check-in, because the next run's start resets it. That is why the SIGTERM handler matters: it turns the common kill into a fail.
urlopen's timeout applies to each blocking operation, such as connecting or waiting for data, not to the whole request (urlopen). Because every check-in carries the schedule, the first run creates the monitor and a deleted monitor comes back on the next run. The schedule and gracePeriod apply only when a check-in creates the monitor; change them later in the monitor's settings. CronRadar reads schedules in UTC, so convert the crontab time if the server isn't on UTC (schedule syntax).
To check results too, write with CheckIn(...) as run:, pass run.outcomes into main(), and set outcomes["rows_written"] = rows there; then configure an expectation such as rows_written >= 100 in the automation's settings. The helper sends the values as JSON with complete, and a missing or violated one opens an incident (API reference). Send counts and booleans only, never rows, payloads, or secrets.
If you'd rather not touch the Python code, the shell wrapper in the dead man's switch guide does the same around any command and passes its exit status through.
Frequently Asked Questions#
Why does my Python script work manually but not in cron?#
Cron runs it with a different interpreter, environment, and directory. python3 resolves on cron's short PATH to the system Python, which lacks your virtualenv's packages; variables from your profile, including PYTHONPATH, are missing; and the job starts in your home directory, which breaks -m imports and relative paths.
How do I run a Python script in a virtualenv from cron?#
Call the environment's interpreter by absolute path, such as cd /srv/rates && /srv/rates/.venv/bin/python -m rates.fetch. Activation isn't needed. With uv, sync at deploy time and use .venv/bin/python, or call uv by full path with --directory and --no-sync.
Why is there no output from my Python cron job?#
Unless you redirect it, cron tries to mail the output, and on a server without a mail transfer agent Debian's cron discards it. Append >> /var/log/app/job.log 2>&1 and run Python with -u, because stdout written to a file is block-buffered.
How do I log errors from a Python cron job?#
Use logging with a timestamped format, which writes to stderr, and redirect stderr into the log file. In an except block, call log.exception() and then raise, so the job still exits 1. For a log file the script opens itself under logrotate, use WatchedFileHandler.
How do I know if my Python cron job failed?#
Cron doesn't alert on a failed job. Make every failure exit nonzero and report each run to a monitor outside the server, which alerts immediately on a fail check-in and also when a run never starts. CronRadar has no installable Python SDK today, so use HTTP check-ins like the helper above.
Put this guide to work
Connect your Python job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
Python SDK connections are temporarily unavailable. Use manual HTTP reporting below. This setup does not discover jobs automatically.