Python Cron Jobs: Scheduling Scripts That Actually Run
Scheduling Python from cron is two lines of crontab and one very common mistake involving virtual environments.
Updated 5 August 2026 · 6 min read
-
Written by
Andrian Valeanu
Founder of Pulsetic
-
Reviewed by
Ionut Caval
Technical reviewer
Call the interpreter and the script by absolute path, and if you use a virtualenv, point at the interpreter inside it rather than activating it: 0 3 * * * /opt/app/venv/bin/python /opt/app/task.py >> /var/log/task.log 2>&1. Cron does not read your shell profile, so python, pyenv and activate are all unavailable to it.
Key takeaways
- Use the venv's own interpreter (
venv/bin/python). There is no need to activate anything. - pyenv and asdf work through PATH shims that cron never sets up, so bare
pythonfails. - Add
-ufor unbuffered output, otherwise logs appear only when the process exits. - Set the working directory with
cdif the script uses relative paths. - Cron is fine for minute-level scheduling. Reach for APScheduler or Celery beat when you need more.
The crontab line
Everything about running Python from cron follows from one fact: cron does not read your shell profile. No virtualenv is active, no version manager is on the path, and python may not resolve to anything at all.
# System Python
0 3 * * * /usr/bin/python3 /opt/app/task.py >> /var/log/task.log 2>&1
# Inside a virtualenv: use its interpreter directly
0 3 * * * /opt/app/venv/bin/python /opt/app/task.py >> /var/log/task.log 2>&1
# With a working directory, for scripts using relative paths
0 3 * * * cd /opt/app && venv/bin/python task.py >> /var/log/task.log 2>&1
Run which python3 in your terminal to get the path to use. If you are inside an activated virtualenv when you run it, you will get the venv interpreter, which is exactly the path you want in the crontab.
Virtualenvs: use the interpreter, not activate
A common first attempt tries to recreate the interactive experience:
# Fragile, and unnecessary
0 3 * * * source /opt/app/venv/bin/activate && python /opt/app/task.py
This tends to fail because cron runs commands with /bin/sh by default, and source is a bash builtin that plain sh does not have. You can work around it by setting SHELL=/bin/bash, but there is no reason to.
Activating a virtualenv does essentially one thing: it puts that environment's bin directory at the front of PATH so that python resolves to it. Calling /opt/app/venv/bin/python directly achieves the same result without the shell gymnastics. The interpreter knows which environment it belongs to and imports from the right site-packages.
You never need to activate a virtualenv in a crontab. Point at venv/bin/python and the environment comes with it.
pyenv, asdf and other version managers
These are the same problem wearing a different hat. They work by inserting a shims directory into PATH from your shell profile, and cron does not read that profile, so python resolves to the system interpreter or to nothing. Ask pyenv for the real path and hard-code it:
pyenv which python
# /home/deploy/.pyenv/versions/3.12.4/bin/python
# Use that path in the crontab
0 3 * * * /home/deploy/.pyenv/versions/3.12.4/bin/python /opt/app/task.py >> /var/log/task.log 2>&1
Unbuffered output
When Python writes to a file or a pipe rather than a terminal, it buffers output in blocks. A long-running job can therefore produce nothing in your log for an hour and then write everything at once, and a job that is killed mid-run may lose its output entirely, including the part explaining what it was doing when it died.
Add -u to flush as it goes. It costs nothing meaningful and makes a tailed log actually useful:
0 3 * * * /opt/app/venv/bin/python -u /opt/app/task.py >> /var/log/task.log 2>&1
The same applies to PYTHONUNBUFFERED=1 if you prefer to set it as an environment variable at the top of the crontab.
Get the schedule right first time
The free cron expression generator builds the five fields for you and previews exactly when the job will next run.
Logging from the script itself
Shell redirection captures whatever the process prints, which is enough for small jobs. Once a job matters, having the script log deliberately is better: you get timestamps, levels, and a file that survives changes to the crontab line.
import logging
import sys
logging.basicConfig(
filename="/var/log/task.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
def main():
logging.info("task started")
# ... do the work ...
logging.info("task finished")
if __name__ == "__main__":
try:
main()
except Exception:
logging.exception("task failed")
sys.exit(1) # non-zero tells anything watching that this run failed
The sys.exit(1) matters more than it looks. Cron itself ignores exit codes, but wrappers, process supervisors and monitoring tools all read them, and a script that swallows its exception and exits 0 has told everything downstream that it succeeded.
When cron is the wrong tool
Cron is excellent at "run this script at this time" and has no opinions beyond that. Some jobs want more.
| Need | Better fit |
|---|---|
| Intervals shorter than a minute | A loop in the script, or a systemd timer |
| Scheduling inside a long-running app | APScheduler |
| Distributed workers, retries, result tracking | Celery beat |
| Task dependencies and backfills | Airflow, Prefect, Dagster |
| Scheduling in a Kubernetes cluster | Kubernetes CronJob |
| The same problem in another language | PHP, Java |
For a nightly report or a data pull every fifteen minutes, cron plus a well-behaved script remains hard to beat. It is already installed, it has no moving parts, and there is no scheduler process of your own to keep alive.
Knowing the job still runs
Once the crontab line is right, the remaining risk is not that Python breaks. It is that something changes around it: a virtualenv rebuilt at a new path, a dependency that starts raising on import, a server rebuilt without the crontab. Cron reports none of these, and a script that stopped running produces no log to notice.
A ping at the end of a successful run closes that gap. If the ping does not arrive on schedule, cron job monitoring alerts you, which covers the failures that leave nothing behind to read.
See how Pulsetic's cron job monitoring catches this from the outside, across 15+ locations.
Frequently asked questions
-
How do I run a Python script with a virtualenv from cron?
Call the virtualenv's own interpreter by absolute path, for example "/opt/app/venv/bin/python /opt/app/task.py". There is no need to activate the environment first, because the interpreter inside it already imports from the right site-packages.
-
Why does my Python cron job say "python: command not found"?
Cron does not read your shell profile, so version manager shims from pyenv or asdf are not on its PATH. Run "which python3" or "pyenv which python" in your terminal and use the absolute path it prints in the crontab.
-
Why is there no output in my Python cron log?
Python buffers output when writing to a file rather than a terminal, so nothing appears until the buffer flushes or the process exits. Add the -u flag, or set PYTHONUNBUFFERED=1, to flush as it goes.
-
Should I use cron or APScheduler?
Use cron for standalone scripts on a schedule: it needs no process of your own and survives reboots. Use APScheduler when the scheduling belongs inside a long-running application that is already running anyway.
-
Catch the next outage before your visitors do.
2-minute setup · Cancel any time
-
No credit card needed