Django Scheduled Tasks: Q2, Beat, or Cron
Choose Q2, Celery Beat, or platform cron for Django scheduled tasks, with rules for missed runs, overlap, time zones, idempotency, and monitoring.
For most Django scheduled tasks, use platform cron plus a Django management command when one fixed schedule is enough. Use Django Q2's scheduler when you already run Q2 and need schedules stored and managed with the application. Use Celery Beat when Celery is already your worker system and you need its routing, schedule stores, and operational ecosystem. Whichever trigger you choose, make the work idempotent and decide what happens to missed or overlapping runs before production.
The scheduler is only the clock. A reliable scheduled job also needs one owner, an intended run time, an overlap policy, a missed-run policy, durable business state, and evidence that useful work completed.
The short version
| Choose | Best fit | Main operational cost |
|---|---|---|
| Platform cron or Kubernetes CronJob | A small number of fixed, deployment-owned management commands | Schedules may live outside Django; dynamic edits and application-level history need extra work |
| Django Q2 scheduler | A Django-first application already using Q2 for workers, results, and admin | Catch-up behavior, broker guarantees, and worker/scheduler availability must be configured deliberately |
| Celery Beat | An application already committed to Celery, especially with multiple queues or richer routing | Beat is another production process and only one scheduler may own a given schedule |
Do not start a scheduler inside AppConfig.ready(), a web request, or every web
worker. Development reloaders and horizontally scaled web processes can start
more than one copy. Run scheduling as an explicit process or let the deployment
platform own it.
On this page
- What does Django provide for scheduled tasks?
- Define the schedule contract before choosing a tool
- When should you use platform cron?
- When should you use the Django Q2 scheduler?
- When should you use Celery Beat?
- How should missed Django scheduled tasks behave?
- How do you prevent overlap and duplicate effects?
- How should scheduled tasks handle time zones?
- How do you monitor scheduled work?
- A worked production pattern
- What does Djass already provide?
- Django scheduled-task checklist
What does Django provide for scheduled tasks?
Django does not include a production scheduler. Django 6 introduced the Tasks framework, which defines how application code describes, validates, queues, and retrieves background work. The official documentation is explicit that Django does not provide the worker mechanism that executes those tasks. Its built-in task backends are intended for development and testing.
Scheduling adds another responsibility. Something must decide that a task is due at 02:00, create or enqueue the run, and remember enough state to decide what happens after downtime. Django's task API can be part of that path when a compatible backend supports it, but it does not choose the production clock, worker, broker, or recurrence policy for you.
Keep these layers separate:
- Schedule definition: when the run is intended to happen.
- Scheduler: the process or platform that notices a due schedule.
- Dispatch: direct command execution or enqueueing work for a worker.
- Worker: the process that performs queued work, if a queue is involved.
- Domain state: the durable record of what the job changed.
- Run evidence: timestamps, status, counts, and alerts operators can inspect.
This separation prevents a common mistake: treating a successful enqueue as a successful business operation. Celery Beat can publish a message, Q2 can create a task, and a platform cron service can start a container, yet the invoice reconciliation or cleanup can still fail later. Track dispatch and useful work as different events.
If you are still deciding whether work belongs outside the request at all, start with the Django background-task comparison and the async views versus background tasks guide. Those decisions come before recurrence.
Define the schedule contract before choosing a tool
A cron expression is not a production specification. Before installing a package, write one schedule contract for every recurring job:
| Contract field | Question to answer |
|---|---|
| Authority | Which single process or platform owns this schedule? |
| Cadence | Is it interval-based, calendar-based, or triggered at one future time? |
| Time zone | Is the business rule UTC or a named local zone, and what happens at daylight-saving transitions? |
| Intended slot | What timestamp identifies this logical run independently of when it starts? |
| Missed-run policy | Catch up every missed slot, run the latest slot once, or skip to the next one? |
| Overlap policy | Allow, skip, queue, or replace when the previous run is still active? |
| Idempotency | What stable key prevents the same logical slot from applying effects twice? |
| Success evidence | What durable state proves the business operation completed? |
| Failure policy | Who retries, how often, and when does an operator intervene? |
The intended slot is the useful addition most setup tutorials omit. Suppose
a daily reconciliation meant for 2026-08-07T00:00:00Z starts six minutes late
after a deployment. Its identity should remain the August 7 slot, not the wall
clock time at which a worker happened to begin. That stable value can become an
idempotency key, a log field, a database uniqueness constraint, and an alert
dimension.
The contract also makes the tool choice smaller. A single daily cleanup with a skip policy may need only platform cron. User-editable schedules with Django admin visibility fit Q2. A Celery installation with routed worker pools and a database-backed schedule store may naturally use Beat. None of those choices removes the need for domain state or duplicate protection.
Keep this contract in the same repository as the code or as versioned platform configuration. If an operator can edit schedules in a database or admin page, log those changes and decide whether code or runtime data is authoritative. Two sources of truth are how duplicate schedules survive migrations.
When should you use platform cron?
Use platform cron when the deployment should own a small set of fixed schedules. The scheduled command starts a one-off process or job, runs a Django management command, reports its exit status, and stops. This is a strong fit for nightly cleanup, daily reconciliation, periodic data refreshes, and other work that does not need user-editable timing.
Put the application logic in a custom Django management command, then configure the platform to run it:
python manage.py reconcile_subscriptions --slot 2026-08-07T00:00:00Z
The exact scheduler can be a managed cron service, a server crontab, or a
Kubernetes CronJob. The guarantees are not identical. Kubernetes, for example,
supports startingDeadlineSeconds, concurrencyPolicy, and a named
.spec.timeZone. Its current documentation also warns that a CronJob can, in
certain circumstances, create two Jobs or no Job for one scheduled time, so
the Job must be idempotent. Treat comparable claims from any hosting provider
as behavior to verify, not as a universal property of cron.
Platform cron works well when:
- the schedule changes only through deployment;
- each run can start a short-lived process;
- the command can connect to the same database and services as the app;
- platform job history and application logs provide enough dispatch evidence;
- you do not need a queue to absorb bursts or route work to a specialist pool.
Its main advantage is failure isolation. A dead web worker does not own the clock, and a duplicated web replica does not create another scheduler. Its main limitation is that the schedule often lives outside Django. If support staff need to pause jobs, inspect next-run times, or create schedules dynamically, an application-owned scheduler may be a better operational surface.
Do not bury activation in an untracked server crontab. Keep the schedule in deployment configuration, document the time zone and overlap policy, and send both the platform result and application completion evidence to monitoring.
When should you use the Django Q2 scheduler?
Use the Django Q2 scheduler when Q2 already runs your background jobs and the
schedule belongs to the Django application. Q2 stores schedules as Django
models, exposes them through Django admin, and lets the running cluster enqueue
due functions. Its schedule documentation
supports one-time, interval, calendar, and cron-style schedules, along with
finite repeats, cluster targeting, and an intended_date_kwarg for the
original scheduled time.
That intended-time argument is valuable for the schedule contract. Pass it to a thin wrapper, normalize it to UTC, and use it as the logical run key:
from django.core.management import call_command
def run_subscription_reconciliation(scheduled_for=None):
call_command("reconcile_subscriptions", slot=scheduled_for)
from django_q.models import Schedule
from django_q.tasks import schedule
schedule(
"apps.billing.tasks.run_subscription_reconciliation",
name="daily-subscription-reconciliation",
schedule_type=Schedule.CRON,
cron="0 2 * * *",
intended_date_kwarg="scheduled_for",
)
Q2's cron schedule type requires the optional croniter dependency. Include
that dependency in the deployed environment and exercise the real expression
in a focused test instead of assuming local development installed the extra.
Q2 makes schedule administration compact, but defaults still encode policy.
Its documentation says missed schedules catch up by default: after downtime,
the scheduler can enqueue past slots until it reaches the future. Setting
catch_up to False changes that behavior so the scheduler runs once after
restart and advances to the next future slot. Neither policy is inherently
correct. Catch-up can be right for daily aggregates; replaying every minute of
a stale polling schedule can create a recovery storm.
Choose Q2 when the application already pays the cost of a Q2 cluster, schedule edits belong in Django, and Q2's queue and routing model fit the workload. Read the Djass Q2 architecture guide for the separate worker process and reliability boundary. If Q2 would be added only to run one management command per day, platform cron is usually the smaller system.
When should you use Celery Beat?
Use Celery Beat when Celery already owns background execution or when the
application needs Celery's mature routing and worker ecosystem. Beat reads
periodic entries from configuration or a custom schedule store and publishes
due tasks for Celery workers. The optional django-celery-beat package stores
schedules in Django's database and provides an admin interface.
Celery's periodic-task documentation states two operational rules directly. First, only one scheduler should own a given schedule; multiple Beat schedulers can publish duplicate tasks. Second, periodic tasks can overlap when one run lasts longer than its interval, so the task needs a locking or idempotency strategy when overlap is unsafe.
Run Beat as its own production process:
celery -A config beat
Embedding Beat in a worker with -B is convenient for a single-node
development setup, but Celery does not recommend that arrangement for
production. A separate process gives deployment health, restart policy, and
ownership a clear boundary.
Celery Beat is a good fit when:
- Celery workers and a broker are already production dependencies;
- periodic work must route to named queues or specialist worker pools;
- the team already monitors Celery task state and worker health;
- schedules need a database-backed admin surface or custom scheduler class;
- workload volume or composition already justifies Celery's larger operating model.
Do not adopt Beat because its cron syntax looks familiar. If the rest of the application uses Q2, adding Celery for scheduling creates two brokers, two task APIs, two worker systems, and two monitoring paths. If there is no queue at all, a platform-owned management command can be easier to deploy and test.
How should missed Django scheduled tasks behave?
Missed-run policy should follow the business meaning of the job. “Run all,” “run once,” and “skip” are different data policies, not scheduler preferences.
Use catch up every slot when each interval represents durable work that must be accounted for independently. Hourly ledger closing or a daily export partition may require every intended slot. Bound the replay rate so recovery does not overwhelm the database or an external API.
Use run the latest slot once when the current state supersedes older work. Refreshing an exchange-rate cache after six missed intervals rarely requires six identical refreshes. Record which slots were collapsed so operators can distinguish an intentional policy from silent loss.
Use skip stale slots when late execution would be harmful or meaningless. A reminder intended for 15 minutes before an event should not arrive two hours after the event. A scheduler deadline can prevent dispatch, but the command should also check freshness because queued work may start late.
Write the policy beside the schedule:
schedule: daily-subscription-reconciliation
cadence: 02:00 Etc/UTC
missed: run every unprocessed calendar day, oldest first, maximum 7 per recovery
overlap: skip dispatch while a prior slot is running
stale: alert when the oldest unprocessed slot exceeds 6 hours
Q2's default catch-up behavior and Kubernetes startingDeadlineSeconds show
why this must be explicit. One scheduler may replay old slots; another may skip
them after a deadline. Celery's behavior depends on the scheduler and schedule
store. The application should still understand the intended slot and reject
work outside its policy.
Test downtime rather than only a normal tick. Stop the scheduler across three due times, restart it, and assert the exact slots created, their order, and the maximum concurrency. A green “scheduler process is running” check cannot prove that recovery semantics match the business rule.
How do you prevent overlap and duplicate effects?
Assume a Django scheduled task can be delivered or started more than once. Celery warns about duplicate publication from multiple Beat schedulers; Kubernetes documents approximate Job creation; manual retries and deployment races add more paths. A lock can reduce concurrent execution, but idempotent domain operations are the stronger boundary because locks expire and processes crash.
Give each logical run a database identity. A minimal run ledger can enforce one row per schedule and intended slot:
from django.db import models
class ScheduledRun(models.Model):
class Status(models.TextChoices):
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
schedule = models.CharField(max_length=100)
intended_for = models.DateTimeField()
status = models.CharField(max_length=16, choices=Status.choices)
started_at = models.DateTimeField()
finished_at = models.DateTimeField(null=True)
result = models.JSONField(default=dict)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["schedule", "intended_for"],
name="one_run_per_schedule_slot",
)
]
The database uniqueness constraint, rather than an in-process flag, arbitrates
two callers racing for the same slot. The winner creates running; the loser
loads the existing record and exits. On success, record a bounded result such
as rows scanned, rows changed, and a non-sensitive cursor. On failure, mark the
run failed and require an explicit retry transition. Define how a stale
running row is recovered after a process dies; do not quietly treat it as
success or automatically steal it without a timeout and audit record.
The work behind the claim must also tolerate repetition. Use unique external idempotency keys for payment APIs, database constraints for one-per-period objects, and compare-before-update logic for state transitions. Do not hold one database transaction open across a long network call merely to preserve a lock. Claim the slot in a short transaction, execute retry-safe work, then record completion.
How should scheduled tasks handle time zones?
Store intended run timestamps as aware UTC datetimes. If the business rule is
expressed in local wall time, store the named IANA zone separately and compute
the UTC instant for each occurrence. A numeric offset such as UTC+3 cannot
represent future daylight-saving changes.
Django 6.1 enables timezone-aware datetimes by default through USE_TZ=True.
Its settings reference
distinguishes Django's application TIME_ZONE from the server's local zone.
Your scheduler may have its own setting too: Celery uses UTC by default but can
use an application timezone, Q2 stores next_run datetimes, and Kubernetes
CronJob uses .spec.timeZone when set.
Decide these cases before launch:
- Does “every day at 09:00” mean 09:00 UTC or 09:00 in the tenant's zone?
- During the spring clock jump, should a nonexistent local time be skipped or moved?
- During the autumn repeated hour, should the job run once or twice?
- If an operator changes the zone, are already calculated future runs reset?
- Does a monthly job on the 31st use the last day of shorter months?
That last rule differs across schedulers. Q2's schedule documentation says a monthly run starting on the 31st moves to the last day of a shorter month and continues from that adjusted day in later months. If your billing policy requires “last calendar day” or “same ordinal day when possible,” model that business rule directly instead of assuming a library's recurrence semantics.
Log both intended_for_utc and the schedule's named zone. In user-facing
interfaces, render local time with the zone name. In tests, include at least
one daylight-saving boundary even if your first customers are all in one zone;
deployment settings change more often than recurrence code does.
How do you monitor scheduled work?
Monitor the last useful completion, not only the scheduler process. A scheduler can be alive while a queue is blocked, a worker is down, credentials are invalid, or every run exits without changing the required state.
For each important schedule, emit and retain:
- schedule name and intended slot;
- dispatch time, start time, and finish time;
- final status and bounded attempt count;
- rows scanned, changed, skipped, and failed where those values are safe;
- the application release and worker identity;
- a non-sensitive error category;
- the age of the oldest unprocessed slot.
Create alerts from the schedule contract. A daily reconciliation expected by 02:00 UTC might page when no successful slot exists by 03:00. A five-minute cache refresh might alert only after several misses. Alert on the business deadline, not on a generic task failure count.
Use separate signals for dispatch and completion. Platform cron or Beat can report that a run was launched. The command or worker should send the success heartbeat only after its transactionally meaningful work is complete. On failure, send a failure signal and keep the domain run row inspectable. The Django health-check guide explains why web readiness and worker freshness are separate contracts, while the production logging guide provides a stable event envelope for requests and workers.
Keep secrets and record payloads out of monitoring. Report identifiers, counts, durations, and error categories. Link the alert to a runbook that tells the operator whether to retry one slot, backfill a range, pause the schedule, or repair data first.
A worked production pattern
Consider daily subscription reconciliation. The user job is not “call a function at 02:00.” It is “make Django's entitlement state agree with verified billing state for each calendar day, without applying a change twice or hiding missed work.”
Build it in four layers:
reconcile_subscriptions --slot <ISO timestamp>is the stable management command. It validates an aware intended slot, claims the unique run row, performs bounded work, records counts, and exits non-zero on failure.- A service function performs the domain transitions. Provider requests use stable idempotency keys where supported; local entitlement changes are guarded by database state and constraints.
- One scheduler owns the cadence. Platform cron runs the command directly, or Q2/Beat invokes a thin wrapper that calls the same command.
- Monitoring expects the daily slot by a defined deadline and alerts on stale
running,failed, or missing rows.
Test the contract with failure cases:
- two callers claim the same slot concurrently;
- the command is retried after success;
- the scheduler is down for three slots;
- one external request times out after the provider accepted it;
- the process dies after claiming but before finishing;
- the prior run lasts into the next cadence;
- a local-zone schedule crosses both daylight-saving transitions;
- an operator backfills a historical range.
The tool choice then becomes an implementation detail. If the application has one fixed reconciliation and no worker queue, platform cron owns step 3. If it already uses Q2, a Q2 schedule can pass the intended date to the wrapper. If Celery already routes billing work to an isolated queue, Beat can publish the task. The command, run ledger, domain idempotency, and monitoring stay the same.
This design also makes migrations safer. You can move the clock from server cron to Q2 or from Q2 to a managed platform without rewriting the business operation. Disable the old authority, enable the new one, and verify that the next intended slot has exactly one run row.
What does Djass already provide?
The current Djass application uses Django Q2 for repository generation. Its
deployment entrypoint runs qcluster as a separate worker process; local
Docker Compose does the same; and the current settings use Redis with explicit
worker, timeout, retry, and attempt values. A repository audit on August 7,
2026 found no application schedule declarations and no explicit Q2 catch_up
override. That means the current system uses Q2 for queued work, but it does
not pretend that a scheduling policy has already been chosen.
Djass-generated repositories include the same Django-first background-worker foundation and can include monitoring support from the generator option catalog. The environment reference shows where runtime configuration belongs, and the Q2 architecture guide explains worker deployment. Scheduled jobs still need an application-specific contract: cadence, intended slot, missed-run policy, overlap behavior, domain state, and completion evidence.
That is the useful product boundary. A starter can give you a maintained queue, settings, worker command, documentation, and tests. It cannot decide whether a missed invoice reconciliation must catch up or whether a stale reminder should be skipped. Those decisions belong to your product.
If you are starting a Django SaaS and want the queue, monitoring, deployment, and agent-readable repository structure already wired, review the available generator modules and current Djass pricing. Then add each recurring job as a small, reviewable feature with its schedule contract beside it.
Django scheduled-task checklist
- [ ] One scheduler or platform is authoritative for each schedule.
- [ ] The task runs in an explicit process, not a web-worker startup hook.
- [ ] The schedule has a named time zone and an aware UTC intended slot.
- [ ] Missed runs have a written catch-up, latest-only, or skip policy.
- [ ] Overlap is allowed, skipped, queued, or replaced intentionally.
- [ ] A stable idempotency key identifies each logical run.
- [ ] Database and external side effects tolerate retries.
- [ ] A stale
runningclaim has a documented recovery path. - [ ] Dispatch success and useful-work completion are separate signals.
- [ ] Monitoring alerts on the business deadline and oldest missing slot.
- [ ] Logs contain identifiers and counts, not secrets or customer payloads.
- [ ] Tests cover duplicate dispatch, downtime, overlap, crash recovery, and daylight-saving transitions.
- [ ] Moving to another scheduler does not require rewriting domain logic.
Choose the smallest scheduler that fits the workload already in your stack. Then spend most of the design effort on the run contract. That is where Django scheduled tasks become reliable production work rather than a cron expression that happens to call Python.