Django doesn't have a built-in way to run tasks on a schedule. No cron, no scheduler, nothing out of the box. But almost every Django app needs it at some point: clean up old data, send emails on a timer, pull updates from an external API. Here are four ways to solve it.
You build your Django app, deploy it, and then realize: how do I make this script run every night at 3 AM? Django doesn't answer that question for you. The framework handles web requests. Everything else (background jobs, scheduled tasks, recurring scripts) is up to you.
The good news: there are well-tested solutions for every situation. A small app on a single server can get away with the simplest approach. A bigger app spread across multiple servers might need something heavier. We've used all four methods below on real projects, and each one fits a different stage of growth.
Management commands and crontab
This is the simplest option, and it's where most Django projects should start.
Django has a built-in concept called "management commands." These are small scripts that you run from the terminal with python manage.py your_command. Django itself ships with dozens of them (migrate, createsuperuser, collectstatic). You can write your own just as easily.
Here's one that deletes expired user sessions from the database:
# myapp/management/commands/clear_expired_sessions.py
from django.core.management.base import BaseCommand
from django.contrib.sessions.models import Session
from django.utils import timezone
class Command(BaseCommand):
help = 'Delete expired sessions from the database'
def handle(self, *args, **options):
deleted, _ = Session.objects.filter(
expire_date__lt=timezone.now()
).delete()
self.stdout.write(f'Deleted {deleted} expired sessions')
You run it once to make sure it works:
python manage.py clear_expired_sessions
# Deleted 47 expired sessions
Now you need something to run it automatically. On Linux, that's cron. You add one line to your server's crontab, and the system runs your command on a schedule:
0 3 * * * cd /var/www/myproject && /var/www/venv/bin/python manage.py clear_expired_sessions >> /var/log/django-cron.log 2>&1
That line says: every day at 3 AM, go to the project folder and run the command. The >> /var/log/django-cron.log part saves any output to a log file so you can check later if something went wrong.
One thing that catches people off guard: cron runs in a very basic environment. It doesn't load your shell profile, doesn't activate your Python virtual environment, and doesn't know about any settings you've configured. That's why the example above uses the full path to the Python binary (/var/www/venv/bin/python) instead of just python. If you use the short version, cron might pick up the wrong Python or not find it at all.
You can also set environment variables at the top of your crontab file, which saves you from repeating them in every line:
DJANGO_SETTINGS_MODULE=myproject.settings.production
DATABASE_URL=postgres://user:pass@localhost/mydb
0 3 * * * cd /var/www/myproject && /var/www/venv/bin/python manage.py clear_expired_sessions
This approach works on any server, requires no extra packages, and is easy to understand. The downside: each scheduled task needs its own crontab line, managed by hand on the server. With 15 tasks, that gets tedious. And if someone rebuilds the server, those entries are gone unless you've saved them somewhere.
django-crontab: keep schedules in your code
Instead of managing crontab entries on the server, django-crontab lets you define them in your Django settings file. The schedules travel with your code, so they're version-controlled and visible to everyone on the team.
pip install django-crontab
# settings.py
INSTALLED_APPS = [
# ...
'django_crontab',
]
CRONJOBS = [
('0 3 * * *', 'myapp.cron.clear_expired_sessions'),
('*/5 * * * *', 'myapp.cron.sync_inventory'),
('0 9 * * 1', 'myapp.cron.send_weekly_digest'),
]
Each line is a pair: a cron expression (when to run) and a Python function (what to run). The functions are regular Python code:
# myapp/cron.py
from django.contrib.sessions.models import Session
from django.utils import timezone
def clear_expired_sessions():
deleted, _ = Session.objects.filter(
expire_date__lt=timezone.now()
).delete()
print(f'Deleted {deleted} expired sessions')
After deploying, one command installs everything into the server's crontab:
python manage.py crontab add # install the schedules
python manage.py crontab show # check what's active
python manage.py crontab remove # remove all entries
Under the hood, it still creates real crontab entries. That's both a strength (uses proven system cron) and a weakness. If you deploy new code and forget to run crontab add, the old schedule stays active. We once spent an hour debugging a mysterious error that turned out to be cron still calling a function we'd renamed two deploys ago.
The package hasn't had a major update since 2021, but it works fine with current versions of Python and Django. For a small or medium project with a handful of scheduled tasks, it hits a nice sweet spot between simplicity and organization.
Celery Beat: for bigger projects
The two approaches above work great for simple tasks on a single server. But what if a task takes 10 minutes to complete? What if it fails and you want it to automatically try again? What if your app runs on three servers and you need to make sure the task only runs once?
That's where Celery comes in. It's the most popular background task system in the Python world, and Celery Beat is its built-in scheduler.
The basic idea: instead of running tasks directly, you put them into a queue (stored in Redis or another message broker). Separate worker processes pick up tasks from the queue and execute them. Celery Beat is a scheduler that automatically adds tasks to the queue at the right times.
pip install celery[redis] django-celery-beat
Setting it up takes a few files:
# myproject/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
# settings.py
from celery.schedules import crontab
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_BEAT_SCHEDULE = {
'clear-expired-sessions': {
'task': 'myapp.tasks.clear_expired_sessions',
'schedule': crontab(hour=3, minute=0),
},
'sync-inventory-every-5-min': {
'task': 'myapp.tasks.sync_inventory',
'schedule': 300.0, # every 300 seconds
},
}
Tasks look like regular functions with a decorator:
# myapp/tasks.py
from celery import shared_task
from django.contrib.sessions.models import Session
from django.utils import timezone
@shared_task(bind=True, max_retries=3)
def clear_expired_sessions(self):
try:
deleted, _ = Session.objects.filter(
expire_date__lt=timezone.now()
).delete()
return f'Deleted {deleted} sessions'
except Exception as exc:
raise self.retry(exc=exc, countdown=60)
Notice the max_retries=3 and self.retry(). If the task fails, Celery waits 60 seconds and tries again, up to three times. That alone saves a lot of headaches with flaky database connections or temporary API outages.
You start Celery with two commands:
celery -A myproject worker --loglevel=info # the worker that runs tasks
celery -A myproject beat --loglevel=info # the scheduler
The honest trade-off: Celery adds real complexity. You need Redis running, worker processes managed by something like supervisord or systemd, and another process for Beat. First-time setup on one of our projects took about 4 hours. But after that, adding each new scheduled task takes 5 minutes.
An extra bonus: install django-celery-beat and you get a schedule editor right inside Django's admin panel. Non-technical team members can adjust how often tasks run without touching any code.
If your project already uses Celery for other things (sending emails in the background, processing file uploads), adding scheduled tasks is almost free. If you don't need Celery for anything else, it's a lot of infrastructure just for a few cron jobs.
Django's new task framework (Django 5.2+)
Starting with version 5.2, Django includes its own task system in django.tasks. No third-party packages needed:
from django.tasks import task
@task()
def clear_expired_sessions():
from django.contrib.sessions.models import Session
from django.utils import timezone
Session.objects.filter(expire_date__lt=timezone.now()).delete()
# Run it from anywhere in your code:
clear_expired_sessions.enqueue()
Simple and clean. But as of late 2026, there's a limitation: the built-in backends only work during development. For production, you need a community package like django-tasks-scheduler, and those are still maturing. The framework handles "do this in the background" well, but "do this every day at 3 AM" still needs an extra tool.
Worth keeping an eye on. The API is stable and the ecosystem is growing fast. In a year or two, this will probably be the default answer. For now, the options above are more battle-tested.
What about Docker and cloud platforms?
Docker containers and cloud platforms like Heroku, Railway, or Render don't give you a traditional crontab. You need a different approach.
If you use Celery, run Beat as a separate container. Same code, different startup command:
# docker-compose.yml
services:
web:
build: .
command: gunicorn myproject.wsgi:application --bind 0.0.0.0:8000
scheduler:
build: .
command: celery -A myproject beat --loglevel=info
restart: unless-stopped
worker:
build: .
command: celery -A myproject worker --loglevel=info
restart: unless-stopped
If you don't use Celery, there's a simpler way: create a URL in your Django app that triggers the task, and let an external service call that URL on a schedule.
# urls.py
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.conf import settings
@require_POST
def run_cleanup(request):
if request.headers.get('X-Cron-Token') != settings.CRON_TOKEN:
return JsonResponse({'error': 'unauthorized'}, status=403)
from django.core.management import call_command
call_command('clear_expired_sessions')
return JsonResponse({'status': 'ok'})
The token check keeps random visitors from triggering your tasks. You then point an external cron service at this URL, set the schedule, and it handles the rest. WatchCron's Cloud Cron does exactly this: it calls your URL on time and also monitors whether the request went through. If your endpoint stops responding or returns an error, you get an alert. Scheduling and monitoring in one step.
This pattern works on any platform. Docker, Heroku, Railway, Render, even serverless. If your app can receive HTTP requests, it can be scheduled. We wrote more about this in the Laravel scheduling guide and the Docker cron guide.
Which one should you pick?
For a small project with a few tasks on one server, start with management commands and crontab. No extra packages, nothing to maintain. Just write the command, add a crontab line, and move on.
When you have more tasks and want them tracked in version control, django-crontab is a nice step up. Same cron underneath, but your schedules live in the codebase.
Celery Beat is the right call when tasks need retries, when they run across multiple servers, or when you want a schedule editor in Django admin. It's more work to set up, but the ceiling is much higher.
On cloud platforms without cron, use an HTTP endpoint with an external trigger. Cloud Cron handles the scheduling and monitoring together.
Plenty of teams mix approaches. Celery for heavy background work, a crontab entry for the nightly backup, an HTTP endpoint for a health check that runs every minute. Use the simplest tool that gets the job done.
Catch failures before they pile up
No matter which method you choose, one risk stays the same: a task quietly stops running and nobody notices. The crontab entry disappears during a server migration. The Celery worker crashes. Redis fills up. None of these show up in your Django error logs.
A simple fix: add a heartbeat ping at the end of each task. After the task finishes, it sends a quick HTTP request to a monitoring service. If that ping stops arriving, you get an alert.
In a management command, it looks like this:
import httpx
from django.core.management.base import BaseCommand
from django.contrib.sessions.models import Session
from django.utils import timezone
class Command(BaseCommand):
def handle(self, *args, **options):
deleted, _ = Session.objects.filter(
expire_date__lt=timezone.now()
).delete()
self.stdout.write(f'Deleted {deleted} expired sessions')
# Tell the monitoring service: task finished successfully
httpx.get('https://ping.watchcron.com/your-monitor-id', timeout=5)
In a Celery task:
@shared_task
def clear_expired_sessions():
deleted, _ = Session.objects.filter(
expire_date__lt=timezone.now()
).delete()
import httpx
httpx.get('https://ping.watchcron.com/your-monitor-id', timeout=5)
return f'Deleted {deleted} sessions'
The ping only goes out after the task succeeds. If it crashes before that line, no ping is sent, and the monitoring service flags it. WatchCron's free plan covers 20 monitors, which is enough for most Django projects. More about heartbeat monitoring in the monitoring guide and the curl heartbeat guide.
WatchCron monitors your scheduled tasks and alerts you when they miss a beat. Free plan includes 20 monitors with Slack, Telegram, email, and 19 other notification channels.