Checklist/Docs/Celery Beat scheduling (with FastAPI)

Guide 14

Celery Beat scheduling (with FastAPI)

Audience: Teams running periodic work (cron-like) next to FastAPI + Celery + RabbitMQ. Scope: Celery Beat as the scheduler; workers still execute tasks. Beat does not run the business logic.

TL;DR

PieceRole
Celery BeatEmits “run this task” messages on a schedule
Celery workersConsume RabbitMQ and execute tasks
Your DB `jobs` tableOptional but recommended for user-visible / auditable runs
RuleRun exactly one Beat (or use a leader-elected scheduler)
text
Beat (single) ──schedule tick──► RabbitMQ ──► workers ──► side effects / job rows

Without single-leader discipline, every Beat replica will duplicate every periodic task.

Contents

  1. When to use Beat
  2. Architecture
  3. Schedule styles
  4. Configuration pseudocode
  5. Periodic task design
  6. One Beat only (HA)
  7. Timezone and DST
  8. Missed runs and catch-up
  9. Observability
  10. Alternatives
  11. Anti-patterns
  12. Checklist

---

1. When to use Beat

FitExample
Fixed intervalEvery 5 minutes: sync stale cache, poll vendor
Cron calendarNightly invoice settle at 02:15
Business day rulesWeekdays 09:00 report (careful with TZ)
Not a fit“Run once when user clicks” → normal enqueue
Not a fitMulti-consumer domain facts → Kafka events

Prefer enqueue a job from Beat (small message) over putting heavy work inside the Beat process. Beat should stay thin.

---

2. Architecture

text
┌─────────────┐ schedule ┌──────────┐ tasks ┌──────────┐
│ Celery Beat │ ────────────────► │ RabbitMQ │ ─────────────► │ workers │
│ (1 leader) │ │ jobs.* │ │ N pods │
└─────────────┘ └──────────┘ └──────────┘
 │ │
 │ never shares process with API │
 ▼ ▼
 separate container/service updates jobs / DB
DeployableCount
APIN
WorkerN (scale on depth)
Beat1 logical leader
Flower0-1 (private)

Same Docker image, different CMD:

bash
# worker
celery -A app.workers.celery_app.celery_app worker -Q jobs.default,jobs.io -c 4

# beat (single replica)
celery -A app.workers.celery_app.celery_app beat -l info

---

3. Schedule styles

Interval

python
# every 300 seconds
"sync-vendor": {
 "task": "app.workers.tasks.sync.pull_vendor",
 "schedule": 300.0,
}

Crontab

python
from celery.schedules import crontab

"nightly-settle": {
 "task": "app.workers.tasks.billing.settle_day",
 "schedule": crontab(hour=2, minute=15),
 "options": {"queue": "jobs.heavy"},
}

Solar / custom schedules

Rare; prefer crontab + explicit TZ. Custom schedules must be serializable if you use persistent schedulers.

Static vs database schedule

ApproachProsCons
beat_schedule in codeSimple, reviewed in PRsDeploy to change
django-celery-beat style DBOps can edit without deployExtra tables; still need one Beat
RedBeat (Redis)Dynamic, HA-friendly lockRedis becomes scheduler dependency

For this checklist (RabbitMQ jobs, Redis not primary job broker), code schedule + one Beat is the default. RedBeat is acceptable if you already operate Redis for cache/locks and want multi-instance Beat with locking.

---

4. Configuration pseudocode

python
# PSEUDOCODE : app/workers/celery_app.py
from celery import Celery
from celery.schedules import crontab
from app.core.settings import settings

celery_app = Celery("app", broker=settings.celery_broker_url)

celery_app.conf.update(
 timezone="UTC", # store and think in UTC
 enable_utc=True,
 task_serializer="json",
 accept_content=["json"],
 beat_schedule={
 "reconcile-outbox-every-minute": {
 "task": "app.workers.tasks.outbox.relay_tick",
 "schedule": 60.0,
 "options": {"queue": "jobs.default"},
 },
 "daily-prune-jobs": {
 "task": "app.workers.tasks.maintenance.prune_old_jobs",
 "schedule": crontab(hour=3, minute=0), # 03:00 UTC
 "options": {"queue": "jobs.default"},
 },
 "weekday-report": {
 "task": "app.workers.tasks.reports.daily_summary",
 "schedule": crontab(hour=8, minute=0, day_of_week="1-5"),
 "args": (), # prefer no fat args
 "options": {"queue": "jobs.io"},
 },
 },
 beat_max_loop_interval=5, # how often Beat wakes to check schedule
)
python
# PSEUDOCODE : settings
class Settings(BaseSettings):
 celery_broker_url: str
 celery_beat_enabled: bool = True # disable in most containers

Only the Beat service should run the beat command. Do not start Beat inside the API process.

---

5. Periodic task design

Thin scheduled tasks

python
# PSEUDOCODE : good: schedule enqueues unit of work
@celery_app.task(name="app.workers.tasks.billing.settle_day")
def settle_day():
 # optional: create a jobs row for audit/UI
 job_id = create_job(type="settle_day", status="pending")
 run_settle(job_id) # or split: only enqueue children

Better for large work:

python
# PSEUDOCODE
@celery_app.task(name="billing.settle_day_fanout")
def settle_day_fanout():
 for merchant_id in list_merchants_due():
 settle_merchant.delay(merchant_id) # many small tasks

Idempotency (mandatory)

Schedules will double-fire under restarts, clock skew, or accidental multi-Beat.

python
# PSEUDOCODE
@celery_app.task(name="reports.daily_summary")
def daily_summary():
 day = utc_today_iso()
 key = f"daily_summary:{day}"
 if not acquire_once(key, ttl=36 * 3600): # Redis lock or UNIQUE job key
 return # already ran
 generate_summary(day)

Or natural key:

sql
UNIQUE (type, period_key) -- type='daily_summary', period_key='2026-08-05'

Don’t pass huge args on the schedule

Beat serializes schedule args into the message. Pass IDs or dates, load data in the worker.

Queue routing

Heavy periodic jobs → jobs.heavy. Don’t block jobs.high with nightly analytics.

---

6. One Beat only (HA)

StrategyHow
Single replicaK8s Deployment replicas=1, or Compose one beat service
Leader electionRedBeat / custom lock: only leader sends
External cronK8s CronJob or system cron HTTP-calls POST /internal/tick (auth!)
yaml
# PSEUDOCODE k8s idea
# Deployment beat: replicas: 1
# PodDisruptionBudget optional; prefer schedule tolerance over multi-beat

Never set replicas: 3 on Beat without a distributed lock. You will get triple charges, triple emails, triple settles.

API-triggered “schedule”

For some teams, replace Beat with:

text
Cloud scheduler / CronJob → POST /internal/schedules/daily-summary
 → enqueue task (same as Beat would)

Still enforce idempotency keys. Protect the endpoint (mTLS, network policy, shared secret).

---

7. Timezone and DST

  • Prefer `timezone="UTC"` and enable_utc=True
  • Convert in the product UI for humans
  • Cron in local time + DST is a common footgun (“runs twice” / “skips an hour”)
  • Document “03:00 UTC = X local” in runbooks
python
# PSEUDOCODE : business local window computed inside task
@celery_app.task
def market_open_tick():
 if not is_market_open(zone="America/New_York"):
 return
 ...

---

8. Missed runs and catch-up

If Beat was down during a tick:

BehaviorNotes
Default Celery BeatGenerally does not replay all missed crons like a wall-clock catch-up engine
Design for driftTasks should be safe if run late or skipped
Critical periodsReconcile job: “ensure day D settled” runnable ad hoc
python
# PSEUDOCODE : reconcilable periodic work
def settle_day(day: str | None = None):
 day = day or utc_yesterday()
 if already_settled(day):
 return
 do_settle(day)

Ops can call settle_day.delay("2026-08-04") after an outage.

---

9. Observability

SignalWhy
Beat process upLiveness; alert if down > 2 minutes
Last tick timestampCustom metric/heartbeat task every minute
Periodic task success ratePer task_name
Duplicate suppress countLock / unique key hits
Queue depth after fan-outNightly storms

Heartbeat pattern:

python
# PSEUDOCODE : schedule every 60s
@celery_app.task(name="ops.beat_heartbeat")
def beat_heartbeat():
 metrics.gauge("beat_heartbeat_unixtime", time.time())

Alert if now - beat_heartbeat_unixtime > 180s.

---

10. Alternatives

ToolUse when
Celery BeatAlready on Celery + need in-process schedules
RedBeatNeed dynamic schedules + multi-instance Beat with Redis lock
K8s CronJobPlatform schedules HTTP/enqueue; fewer moving parts in app
Taskiq schedulerTaskiq stack instead of Celery
APScheduler in APIAvoid for multi-replica APIs (duplicate fires)
Kafka + time windowsStream processing, not simple cron

APScheduler embedded in each API replica is the FastAPI analogue of “accidentally multi-Beat.”

---

11. Anti-patterns

Anti-patternFix
Beat + worker + API in one containerSplit processes
replicas: 3 on Beat, no lockSingle leader or RedBeat
Heavy CPU inside Beat processBeat only dispatches
Non-idempotent daily charge taskUnique period key + lock
Local TZ crons without DST reviewUTC + explicit business rules
Silent miss after Beat outageReconcile commands + alerts
Schedule args with secretsSettings/vault in worker

---

12. Checklist

  • [ ] Beat is a separate deployable from API and workers
  • [ ] Exactly one leader (replicas=1 or distributed lock)
  • [ ] timezone documented (prefer UTC)
  • [ ] Periodic tasks idempotent (period key / lock)
  • [ ] Heavy work fanned out to workers/queues
  • [ ] JSON tasks only; no pickle
  • [ ] Heartbeat metric + alert if Beat silent
  • [ ] Runbook: re-run missed day (settle_day(day=...))
  • [ ] Graceful deploy: workers drain; Beat restart acceptable with idempotency
  • [ ] Flower/RMQ UI private; schedules reviewed in code review or controlled admin

---

Pseudocode: full minimal setup

python
# PSEUDOCODE : tasks/maintenance.py
@celery_app.task(name="maintenance.prune_old_jobs")
def prune_old_jobs(days: int = 30):
 deleted = db.execute(
 "DELETE FROM jobs WHERE status IN ('succeeded','failed') "
 "AND finished_at < now() - interval '%s days'" % days
 ) # use bound params in real code
 log.info("pruned", extra={"deleted": deleted})

# celery_app.conf.beat_schedule entry points at this task daily
bash
# production processes
celery -A app.workers.celery_app.celery_app worker -Q jobs.default,jobs.io,jobs.heavy
celery -A app.workers.celery_app.celery_app beat -l info

---

See also

External docs