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:
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.

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:
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.
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.
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")
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 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()
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.
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
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.
Python support in Encore is in beta, and you can sign up for access to try it with your Django project.
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.
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.
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.
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.