# Python Script Cron Job: Virtualenvs, Paths, and Logging

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/python-cron-job

> Run a Python script from cron with the right virtualenv interpreter, imports, and environment, plus logs, exit codes, timeouts, and an alert when it fails.

**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](https://cronradar.com/blog/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](https://cronradar.com/blog/python-scheduler-monitoring); for Django management commands and Celery beat, see [Django cron monitoring](https://cronradar.com/blog/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](https://packages.debian.org/trixie/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:

```text
* * * * * python3 -c 'import os, sys; print(sys.executable, os.getcwd(), *sys.path, sep="\n")' > /tmp/cron-python.txt 2>&1
```

Packages installed with `pip install --user` live in the account's [user site directory](https://docs.python.org/3/library/site.html#site.USER_SITE), `~/.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](https://docs.python.org/3/library/venv.html#how-venvs-work) 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:

```text
15 2 * * * cd /srv/rates && /srv/rates/.venv/bin/python -m rates.fetch
30 3 * * * cd /srv/analytics && /srv/analytics/.venv/bin/dbt run
```

The 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](https://github.com/astral-sh/uv/blob/0.13.5/docs/concepts/projects/sync.md)), and it includes the `dev` dependency group unless told otherwise ([default groups](https://github.com/astral-sh/uv/blob/0.13.5/docs/concepts/projects/dependencies.md#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:

```text
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 `uv` in `$XDG_BIN_HOME`, `$XDG_DATA_HOME/../bin`, or `~/.local/bin` ([storage reference](https://github.com/astral-sh/uv/blob/0.13.5/docs/reference/storage.md#executable-directory)), none of which is on cron's `PATH`. `command -v uv` in your shell prints it.
- **Use `--directory`, not `--project`.** `--project` only tells uv where to find `pyproject.toml` and `.venv`; relative paths still resolve against the current directory, which under cron is your home. `--directory` changes into the project first ([uv CLI source](https://github.com/astral-sh/uv/blob/0.13.5/crates/uv-cli/src/lib.rs#L320-L341)).
- **Pick a sync mode.** `--no-sync` skips the environment check and implies `--frozen`, which uses `uv.lock` without checking it; `--locked` makes the run fail if the lockfile is out of date ([run options](https://github.com/astral-sh/uv/blob/0.13.5/crates/uv-cli/src/lib.rs#L3380-L3405)).

A PEP 723 script with the shebang `#!/usr/bin/env -S uv run --script` ([uv scripts guide](https://github.com/astral-sh/uv/blob/0.13.5/docs/guides/scripts.md#using-a-shebang-to-create-an-executable-file)) 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](https://github.com/pypa/pipx/blob/1.18.4/docs/how-to/configure-paths.rst)). 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](https://docs.python.org/3/library/sys.html#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 raises `KeyError` and 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](https://github.com/theskumar/python-dotenv/blob/v1.2.4/src/dotenv/main.py#L388-L425) with an explicit path. python-dotenv 1.2.4 returns `False` rather than raising when the file is missing, so the `os.environ[...]` read is what catches it. BusyBox crond ignores crontab `NAME=value` lines.
- **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](https://docs.python.org/3/library/sys.html#sys.stdout)). `print()` output shows up late, out of order with log lines, or not at all if the process is killed. [`-u`](https://docs.python.org/3/using/cmdline.html#cmdoption-u) fixes it everywhere; `PYTHONUNBUFFERED=1` does 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](https://peps.python.org/pep-0538/), [PEP 540](https://peps.python.org/pep-0540/)). Python 3.15 uses UTF-8 by default whatever the locale ([What's New in 3.15](https://docs.python.org/3.15/whatsnew/3.15.html)). On 3.14 and earlier, `open()` without an `encoding` still follows a non-UTF-8 locale if cron passes one, so write `encoding="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 `-m` from the project directory instead.

## Exit Nonzero on Failure and Log With Timestamps

An uncaught exception prints its traceback to stderr ([sys.excepthook](https://docs.python.org/3/library/sys.html#sys.excepthook)), and the interpreter exits with status 1 ([Py_RunMain](https://docs.python.org/3/c-api/interp-lifecycle.html#c.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 with `sys.exit(main())`. `None` counts as 0; `sys.exit("text")` prints the text to stderr and exits 1 ([sys.exit](https://docs.python.org/3/library/sys.html#sys.exit)).
- Return 1 when the job did only part of its work, such as 3 of 40 uploads failing.
- Never end an `except` block with a log line alone; that turns every failure into exit 0. If you catch to log, re-raise:

```python
try:
    partial.replace(target)
except OSError:
    log.exception("could not replace %s", target)
    raise
```

[`log.exception`](https://docs.python.org/3/library/logging.html#logging.Logger.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`](https://docs.python.org/3/library/logging.handlers.html#logging.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`](https://docs.python.org/3/library/logging.handlers.html#logging.handlers.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)](https://man7.org/linux/man-pages/man8/logrotate.8.html)). 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](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts)); 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=None` turns that off ([docs](https://www.python-httpx.org/advanced/timeouts/)).
- **urllib** uses the socket module's default, which is no timeout ([socket](https://docs.python.org/3/library/socket.html#socket.getdefaulttimeout)); pass `timeout=` to `urlopen`.
- **subprocess.run(..., timeout=600)** kills the child and raises `TimeoutExpired` ([subprocess](https://docs.python.org/3/library/subprocess.html#subprocess.run)).

Then wrap the whole run in `timeout` and `flock` on the crontab line, as the complete example below does. [Cron job timeout](https://cronradar.com/blog/cron-job-timeout) covers timeout's exit codes and BusyBox's version, and [prevent overlapping cron jobs](https://cronradar.com/blog/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](https://docs.python.org/3/library/signal.html#general-rules)). 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`](https://docs.python.org/3/library/atexit.html) functions are skipped. Convert it into `SystemExit`:

```python
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](https://github.com/coreutils/coreutils/blob/v9.12/src/timeout.c#L229-L250)), 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](https://docs.python.org/3/library/signal.html#execution-of-python-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:

```text
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>&1
```

- `cd /srv/rates` makes `-m rates.fetch` importable without installing the project.
- `flock -n` skips the run if the previous one still holds the lock.
- `timeout -k 1m 20m` sends SIGTERM at 20 minutes and SIGKILL a minute later.
- `-u` keeps output unbuffered; `>> ... 2>&1` sends stdout, stderr, and tracebacks to one file the job's account can write.

The script, using python-dotenv for its settings file:

```python
# /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`](https://docs.python.org/3/library/os.html#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](https://cronradar.com/blog/cron-dead-mans-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](https://docs.cronradar.com/integrations/python) 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:

```python
# /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:

```python
    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](https://docs.python.org/3/library/urllib.request.html#urllib.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](https://docs.cronradar.com/schedules)).

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](https://docs.cronradar.com/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](https://cronradar.com/blog/cron-dead-mans-switch) 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.
