Fix a Django Management Command That Fails in Cron
Fix Django management commands that work in your shell but fail or do nothing in cron: use the virtualenv Python with full paths, load environment variables and settings, set the working directory, and capture output and errors.
Problem statement
python manage.py send_reminders works perfectly when you run it by hand. In crontab it either produces no effect at all or fails silently:
0 7 * * * python manage.py send_reminders
Cron runs commands in a minimal environment: a short PATH, no virtualenv activation, none of the variables your shell profile exports, and the user's home directory as the working directory. A Django command that relies on any of those fails, and because cron's output usually goes to local mail nobody reads, the failure is invisible.
- Use absolute paths:
/srv/app/.venv/bin/python /srv/app/manage.py send_reminders. - Load environment variables explicitly (an env file sourced in a wrapper script).
cdto the project directory or pass full paths.- Redirect output to a log:
>> /var/log/app/cron.log 2>&1. - Prefer systemd timers, which use the same
EnvironmentFileand journal as your web service.
Step-by-step solution
1. Reproduce cron's environment
Run the command the way cron does to see the real error:
env -i HOME=$HOME SHELL=/bin/sh PATH=/usr/bin:/bin /bin/sh -c 'cd ~ && python manage.py send_reminders'
Typical results: python: not found, ModuleNotFoundError: No module named 'django', can't open file 'manage.py', or ImproperlyConfigured: The SECRET_KEY setting must not be empty.
2. Use the virtualenv's Python
You do not need to activate a virtualenv; calling its interpreter directly uses its packages:
0 7 * * * /srv/app/.venv/bin/python /srv/app/manage.py send_reminders
3. Load the environment with a wrapper
Settings that read os.environ need the same variables the web service has. Keep them in one env file and source it in a small script:
#!/bin/sh
# /srv/app/bin/manage-cron
set -eu
cd /srv/app
set -a
. /etc/myapp/env
set +a
exec /srv/app/.venv/bin/python manage.py "$@"
chmod 750 /srv/app/bin/manage-cron
0 7 * * * /srv/app/bin/manage-cron send_reminders >> /var/log/myapp/cron.log 2>&1
set -a exports every variable defined while sourcing the file. The env file must use plain KEY=value lines without export or quotes that the shell would interpret differently than systemd does.
4. Capture output and failures
Redirect both streams to a log, and make failures visible:
MAILTO=""
0 7 * * * /srv/app/bin/manage-cron send_reminders >> /var/log/myapp/cron.log 2>&1 || curl -fsS https://hc-ping.com/<uuid>/fail
A heartbeat service (Healthchecks.io, Cronitor, Better Stack) alerts you when a job fails or does not run at all. Make sure the log directory exists and is writable by the cron user.
5. Check which user and crontab
crontab -e edits the current user's crontab; /etc/cron.d/myapp entries need a user field:
# /etc/cron.d/myapp
0 7 * * * deploy /srv/app/bin/manage-cron send_reminders >> /var/log/myapp/cron.log 2>&1
The user must be able to read the env file and the project. Files in /etc/cron.d must not contain dots in their names and need a trailing newline.
6. Consider systemd timers instead
A systemd service plus timer reuses the web service's EnvironmentFile, logs to the journal, records exit status, and can catch up missed runs with Persistent=true:
# /etc/systemd/system/send-reminders.service
[Service]
Type=oneshot
User=deploy
WorkingDirectory=/srv/app
EnvironmentFile=/etc/myapp/env
ExecStart=/srv/app/.venv/bin/python manage.py send_reminders
See Schedule Django Management Commands With systemd Timers.
Verification check: temporarily schedule the job a few minutes ahead, then check the log file and the heartbeat dashboard. grep CRON /var/log/syslog (or journalctl -u cron) confirms cron started it.
Explanation
Cron is deliberately minimal: it starts each command with /bin/sh, a fixed PATH, and a handful of variables, without reading login profiles. That makes jobs predictable but means anything your shell setup provides, such as an activated virtualenv, exported secrets, or a working directory, is absent. Django management commands depend on all three to import the project and load settings, which is why they are a classic source of "works by hand, fails in cron" problems.
Edge cases / notes
- Percent signs in crontab lines are special (newline); escape them as
\%in date formats. - Time zones. Cron uses the system time zone unless
CRON_TZis set; a server in UTC runs0 7at 07:00 UTC. - Overlapping runs. Long jobs can overlap the next run; use
flock -n /tmp/job.lockin the command. - Containers. Running cron inside an app container is awkward; use a scheduler service, Kubernetes CronJobs, or Celery Beat instead.
Next steps
- Move scheduled jobs to systemd Timers for Django Management Commands.
- For many recurring tasks, Set Up Celery Beat for Django in Production.
FAQ
Why does my Django command work manually but not in cron?
Cron does not activate your virtualenv, load your environment variables, or start in your project directory.
Do I need to activate the virtualenv in cron?
No. Call the virtualenv's Python by its absolute path.
Where does cron output go?
To local mail for the user by default. Redirect it to a log file explicitly.
Are systemd timers better than cron?
For Django jobs on systemd servers, usually yes: shared environment files, journal logging, and exit status tracking.