Oct 27, 20266 min read

How to Add Cron Jobs to a Django App

Running scheduled work like session cleanup from a Django project with Encore, Celery beat, APScheduler or a system crontab, with idempotent jobs and one run per schedule.

Django projects collect jobs that belong on a schedule, such as deleting expired sessions, sending digests or syncing a partner API. Django has no scheduler, so the job ends up in a crontab on one server or in a scheduler process that someone has to keep running.

This guide schedules session cleanup in a Django project with Encore, Celery beat, APScheduler and a system crontab. Each version deletes expired rows from django_session once a day, which is what Django's clearsessions command is for.

What you need to decide:

  • Where the schedule lives, in code or on a server.
  • How many times the job runs when you have several instances.
  • What happens when a run fails or overlaps the next one.
  • How you test the job locally.

Cron options for Django at a glance

CapabilityEncoreCelery beatAPSchedulerSystem crontab
Schedule defined inPython code next to the appCelery configPython code in the web processA file on one server
Runs per scheduleOnce, by a managed schedulerOnce if a single beat runsOnce per processOnce per server
Extra processesNoneBroker, beat and workerNonecron daemon on a host
Local testingCall the endpoint under encore runRun broker, beat and workerRuns inside runserverRun the command by hand
ProductionManaged schedule in your AWS or GCP accountYou run beat and workersTied to web process lifetimeTied to one machine

Encore: cron jobs for your existing Django project

Encore

Encore combines cron jobs and the rest of your Django project's infrastructure in Python code, with encore run starting Postgres locally and a deploy provisioning the schedule, database and service in your own AWS or GCP account. It is an infrastructure SDK added to an existing project, and the views, models and Django migrations stay as they are.

Encore declares infrastructure in Python code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in Python, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

The database and the job are declared in accounts/resources.py. A cron job names an HTTP path, and when it is due Encore sends a POST request to that path on the service:

from encore import cron from encore.storage import postgres # Django manages the schema through its own migrations. db = postgres.Database("accounts") # When the job is due, Encore sends POST /internal/clear-sessions to this service. clear_sessions = cron.Job( "clear-sessions", path="/internal/clear-sessions", title="Delete expired Django sessions", schedule="0 4 * * *", )

The schedule uses standard cron syntax, here 04:00 UTC every day, and crontab.guru is a quick way to check an expression. For fixed intervals, every="1h" replaces schedule.

The view does the work. It only deletes sessions whose expiry is in the past, so a second run on the same day deletes nothing:

from django.contrib.sessions.models import Session from django.http import HttpRequest, JsonResponse from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @csrf_exempt # Called by the scheduler, not by a browser with a session cookie. @require_POST def clear_sessions(request: HttpRequest) -> JsonResponse: # Safe to run twice: it only deletes sessions that have already expired. deleted, _ = Session.objects.filter(expire_date__lt=timezone.now()).delete() return JsonResponse({"deleted": deleted})

The URL has no trailing slash, so it matches the job's path exactly:

from django.urls import path from accounts import views urlpatterns = [ # No trailing slash, to match the cron job's path exactly. path("internal/clear-sessions", views.clear_sessions), ]

settings.py builds DATABASES from the declared database, so Django and Encore point at the same Postgres:

from psycopg.conninfo import conninfo_to_dict from accounts.resources import db connection = conninfo_to_dict(db.connection_string) DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": connection.pop("dbname"), "USER": connection.pop("user", ""), "PASSWORD": connection.pop("password", ""), "HOST": connection.pop("host", ""), "PORT": connection.pop("port", ""), "OPTIONS": connection, } }

Cron jobs do not fire on a schedule under encore run, so you test the job by calling the endpoint. encore run starts Postgres locally, and migrations run through encore exec against the same database:

~/accounts
$ encore exec -- uv run python manage.py migrate$ encore run$ curl -X POST localhost:4000/internal/clear-sessions{"deleted": 0}

Each run gets a trace with the SQL it executed. On deploy, Encore provisions the database as RDS on AWS or Cloud SQL on GCP in your own account, along with the scheduler that calls the job.

Because the schedule sits in the same file as the database, an agent adding a weekly digest writes one more cron.Job and a view, tests it with a local request, and the deploy picks up the new schedule.

Good to know

The scheduler calls one instance per run, so scaling the web service does not multiply the job. A run that takes longer than the interval can still overlap the next one, so keep jobs short or take a Postgres advisory lock at the start.

Try Encore →

Celery beat

Celery projects add a schedule with beat, which sends a task to the broker on each tick. The periodic tasks guide configures it in beat_schedule:

from celery import Celery from celery.schedules import crontab from django.core.management import call_command app = Celery("accounts") app.config_from_object("django.conf:settings", namespace="CELERY") app.conf.beat_schedule = { "clear-sessions": { "task": "accounts.tasks.clear_sessions", "schedule": crontab(hour=4, minute=0), }, } @app.task(name="accounts.tasks.clear_sessions") def clear_sessions() -> None: call_command("clearsessions")

Good to know

You run a broker, one celery beat process and at least one worker in each environment. Running beat in two places sends every task twice, so beat is usually a single dedicated container. django-celery-beat moves the schedule into the database and the admin.

APScheduler

APScheduler runs jobs inside the Python process, often started from an AppConfig.ready() hook:

from apscheduler.schedulers.background import BackgroundScheduler from django.apps import AppConfig class AccountsConfig(AppConfig): name = "accounts" def ready(self) -> None: from django.core.management import call_command scheduler = BackgroundScheduler() scheduler.add_job(call_command, "cron", hour=4, args=["clearsessions"]) scheduler.start()

Good to know

Each web process starts its own scheduler, so several Gunicorn workers run the job several times, a limitation the APScheduler FAQ covers. Jobs stop when the process scales to zero or restarts, which suits a single long-lived process better than an autoscaled service.

System crontab

The oldest option is a line in a crontab on a server that also has the code and settings:

# m h dom mon dow command 0 4 * * * cd /srv/accounts && .venv/bin/python manage.py clearsessions

Good to know

The schedule lives on one machine, outside the repository, with its own environment variables. Containers and autoscaled platforms have no long-lived host for it, and a failed run only shows up if you capture cron's output.

How to choose

  • Encore if you want the schedule in the repository next to the database and the job running once per tick in your own AWS or GCP account.
  • Celery beat if the project already runs Celery for background tasks.
  • APScheduler for a single long-lived process where duplicate runs cannot happen.
  • A system crontab for one server you manage by hand.

Python support in Encore is in beta, and you can sign up for access to try it with your Django project.

Frequently asked questions

How do I run a cron job in a Django app?

Put the work in a view or management command that is safe to run twice, then have one scheduler call it. With Encore, declare cron.Job("clear-sessions", path="/internal/clear-sessions", title="Delete expired Django sessions", schedule="0 4 * * *") and route that path to a Django view. On deploy, Encore calls the endpoint on schedule in your AWS or GCP account.

Why does my Django scheduled job run several times?

An in-process scheduler such as APScheduler starts once per worker process, so four Gunicorn workers on two instances run the job eight times. Use a single external scheduler, or make the job idempotent and guard it with a lock.

Do Django sessions get deleted automatically?

No. With the database session backend, expired rows stay in the django_session table until something deletes them. Django provides the clearsessions management command for this, and it is meant to run on a schedule.

Does Celery beat replace cron for Django?

Celery beat sends tasks to a Celery broker on a schedule, and workers run them. You run exactly one beat process next to the broker and workers, because two beat processes send every task twice.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders