Back to guides

Guides · April 24, 2026

Django cron jobs: every way to schedule Django in 2026 (with code)

Four ways to schedule Django in production, with code for each. Pick crontab + manage.py for a single VPS, django-q2 or Celery Beat for in-process schedules, Heroku or Render for platform cron, or external HTTP cron when you want retries and alerts without an always-on scheduler.
crontap.com / guides
Four credible ways to schedule Django: crontab + manage.py, in-process schedulers (django-q, django-celery-beat, APScheduler), platform cron, and external HTTP cron. Code for each, plus when to pick which.

Say you run a Django 5.x app and need a cleanup_stale_drafts task every night to purge week-old draft posts, a weekly report email on Mondays, an hourly Stripe reconciliation, and a daily content moderation pass over user uploads. Django ships zero scheduler out of the box. You have four credible ways to wire any of those: a Unix crontab calling python manage.py, an in-process scheduler like django-q2 or Celery Beat, a platform scheduler like Heroku Scheduler or Render Cron Jobs, or an external HTTP cron that hits a locked-down Django view.

This guide is the side-by-side walkthrough with code, plus when to pick which. For the broader Python picture, see the Python cron jobs guide.

Path 1: crontab calling python manage.py {custom_command}

The classic Django shape. Write a custom management command at myapp/management/commands/cleanup_stale_drafts.py, then fire it from cron.

# crontab -e
0 3 * * * cd /opt/myproject && /opt/myproject/.venv/bin/python /opt/myproject/manage.py cleanup_stale_drafts >> /var/log/cleanup_stale_drafts.log 2>&1

Three things bite people here:

  1. PATH is tiny under cron. Use the absolute path to the venv's python, not bare python3.
  2. No virtualenv autoload. cd into the project, or set DJANGO_SETTINGS_MODULE=myproject.settings.production explicitly in the crontab line.
  3. Absolute path to manage.py. Relative paths break when cron's working directory is /.

Pick this when: one VPS, one team, and you are fine grepping /var/log when something breaks at 3am.

Path 2: In-process schedulers

For teams already running a worker process, keep the schedule in Python.

  • django-q2 is the actively maintained fork of django-q. Define schedules in the admin or in code, store them in the DB, and run a qcluster process under supervisord or systemd. Decent retry semantics and the friendliest schedule UI of the three.
  • django-celery-beat is the answer when you already run Celery for async tasks. Beat stores schedules in the DB and dispatches to your existing workers. Cost: a broker (Redis or RabbitMQ), the beat process, plus the workers.
  • APScheduler runs inside a single Django management command like python manage.py runapscheduler. Light, no broker, but you must keep exactly one instance alive or jobs double-fire.

Tradeoff for all three: you pay for an always-on process whose only job is the clock. On Heroku that is a full extra dyno.

Path 3: Platform cron

If your Django app already lives on a managed platform, use its native scheduler.

  • Heroku Scheduler runs every 10 minutes, hourly, or daily only, and fires python manage.py cleanup_stale_drafts in a one-off dyno. No minute cadence, no cron syntax.
  • Render Cron Jobs support standard cron syntax down to the minute and run your Docker image with the command of your choice.
  • AWS EventBridge fires a Lambda or ECS task that runs the management command. The wrinkle: cold starts, IAM, and packaging your Django app for Lambda (Zappa or a container image).

Path 4: External HTTP cron hitting a Django view

Expose a locked-down view and let an external cron service hit it. django.core.management.call_command runs your existing management command in-process, no subprocess.

import os
from django.core.management import call_command
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
 
@csrf_exempt
@require_POST
def run_cleanup_stale_drafts(request):
    expected = f"Bearer {os.environ['CRON_SECRET']}"
    if request.headers.get("Authorization") != expected:
        return HttpResponseForbidden()
    call_command("cleanup_stale_drafts")
    return HttpResponse(status=204)

In Crontap, register POST https://api.yourapp.com/internal/cleanup-stale-drafts, set the Authorization: Bearer <secret> header, schedule 0 3 * * *, pick America/New_York. Pro is $3.25/mo annual flat for unlimited HTTP schedules at minute cadence on a 1-minute floor.

Schedule your Django jobs from outside the box. Free forever tier with one schedule. Try Crontap →

How to make a Django cron idempotent

Cron fires whether or not yesterday's run finished. The cleanest Django pattern is a transaction.atomic block with select_for_update on a single-row job lock table:

from django.db import transaction
from django.utils import timezone
from .models import JobLock
 
with transaction.atomic():
    lock = JobLock.objects.select_for_update().get(name="cleanup_stale_drafts")
    # do work here
    lock.last_ran_at = timezone.now()
    lock.save()

If a second invocation arrives while the first is mid-flight, it blocks on the row until the first commits, then sees the fresh last_ran_at and no-ops. Alternative: a UniqueConstraint on (job_name, run_date) so a duplicate INSERT raises IntegrityError and you treat that as "already ran, skip."

FAQ

Should I use Celery Beat or django-q2?

Use Celery Beat if you already run Celery for background tasks. If you do not, django-q2 is much less infrastructure (one Redis or DB broker, one qcluster process) and the admin UI for managing schedules is friendlier.

How do I run a Django management command from a view?

Call django.core.management.call_command("your_command") from inside a POST handler. Authenticate the request with a bearer token (see Path 4) and use @require_POST + @csrf_exempt so external callers can hit it. It runs synchronously in the request process, so keep the command short or enqueue work for a django-q2 or Celery worker.

What about Django + Postgres pg_cron?

pg_cron is fine for pure-SQL maintenance, but you lose Django's ORM, signals, and middleware. Prefer it for VACUUM, partition rotation, or materialized view refreshes, not for application logic.

How do I avoid double-firing when scaling horizontally?

Path 1 and Path 2 both struggle here. Either elect a single "scheduler" instance (Celery Beat with --without-mingle, or one designated qcluster host) or move the schedule out of the app entirely with Path 3 or Path 4. With external HTTP cron the schedule fires once and your load balancer picks one instance to answer.

Related on Crontap

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Alternatives

Vercel Cron every minute: beating the Hobby hourly limit

Vercel Cron caps Hobby at hourly cadence and 5 jobs, and ties every change to a redeploy. Here is the external cron pattern teams use to ship per-minute schedules, per-IANA timezones, and one dashboard across projects without paying $20/mo per user for Pro.

Alternatives

Cloud Run cron without Cloud Scheduler

Cloud Scheduler costs $0.10 per job per month after the first 3 and asks for OIDC plus IAM bindings on every target. Here is the IAM-free pattern Cloud Run teams use to fire their .run.app URLs on a clock with one bearer token and one dashboard across every GCP project.