Back to blog
By Rasul

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?

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:

  1. Schedule definition: when the run is intended to happen.
  2. Scheduler: the process or platform that notices a due schedule.
  3. Dispatch: direct command execution or enqueueing work for a worker.
  4. Worker: the process that performs queued work, if a queue is involved.
  5. Domain state: the durable record of what the job changed.
  6. 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:

  1. 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.
  2. 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.
  3. One scheduler owns the cadence. Platform cron runs the command directly, or Q2/Beat invokes a thin wrapper that calls the same command.
  4. 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 running claim 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.