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
| Piece | Role |
|---|---|
| Celery Beat | Emits “run this task” messages on a schedule |
| Celery workers | Consume RabbitMQ and execute tasks |
| Your DB `jobs` table | Optional but recommended for user-visible / auditable runs |
| Rule | Run exactly one Beat (or use a leader-elected scheduler) |
Beat (single) ──schedule tick──► RabbitMQ ──► workers ──► side effects / job rowsWithout single-leader discipline, every Beat replica will duplicate every periodic task.
Contents
- When to use Beat
- Architecture
- Schedule styles
- Configuration pseudocode
- Periodic task design
- One Beat only (HA)
- Timezone and DST
- Missed runs and catch-up
- Observability
- Alternatives
- Anti-patterns
- Checklist
---
1. When to use Beat
| Fit | Example |
|---|---|
| Fixed interval | Every 5 minutes: sync stale cache, poll vendor |
| Cron calendar | Nightly invoice settle at 02:15 |
| Business day rules | Weekdays 09:00 report (careful with TZ) |
| Not a fit | “Run once when user clicks” → normal enqueue |
| Not a fit | Multi-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
┌─────────────┐ schedule ┌──────────┐ tasks ┌──────────┐
│ Celery Beat │ ────────────────► │ RabbitMQ │ ─────────────► │ workers │
│ (1 leader) │ │ jobs.* │ │ N pods │
└─────────────┘ └──────────┘ └──────────┘
│ │
│ never shares process with API │
▼ ▼
separate container/service updates jobs / DB| Deployable | Count |
|---|---|
| API | N |
| Worker | N (scale on depth) |
| Beat | 1 logical leader |
| Flower | 0-1 (private) |
Same Docker image, different CMD:
# 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
# every 300 seconds
"sync-vendor": {
"task": "app.workers.tasks.sync.pull_vendor",
"schedule": 300.0,
}Crontab
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
| Approach | Pros | Cons |
|---|---|---|
beat_schedule in code | Simple, reviewed in PRs | Deploy to change |
django-celery-beat style DB | Ops can edit without deploy | Extra tables; still need one Beat |
| RedBeat (Redis) | Dynamic, HA-friendly lock | Redis 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
# 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
)# PSEUDOCODE : settings
class Settings(BaseSettings):
celery_broker_url: str
celery_beat_enabled: bool = True # disable in most containersOnly the Beat service should run the beat command. Do not start Beat inside the API process.
---
5. Periodic task design
Thin scheduled tasks
# 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 childrenBetter for large work:
# 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 tasksIdempotency (mandatory)
Schedules will double-fire under restarts, clock skew, or accidental multi-Beat.
# 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:
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)
| Strategy | How |
|---|---|
| Single replica | K8s Deployment replicas=1, or Compose one beat service |
| Leader election | RedBeat / custom lock: only leader sends |
| External cron | K8s CronJob or system cron HTTP-calls POST /internal/tick (auth!) |
# PSEUDOCODE k8s idea
# Deployment beat: replicas: 1
# PodDisruptionBudget optional; prefer schedule tolerance over multi-beatNever 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:
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
# 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:
| Behavior | Notes |
|---|---|
| Default Celery Beat | Generally does not replay all missed crons like a wall-clock catch-up engine |
| Design for drift | Tasks should be safe if run late or skipped |
| Critical periods | Reconcile job: “ensure day D settled” runnable ad hoc |
# 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
| Signal | Why |
|---|---|
| Beat process up | Liveness; alert if down > 2 minutes |
| Last tick timestamp | Custom metric/heartbeat task every minute |
| Periodic task success rate | Per task_name |
| Duplicate suppress count | Lock / unique key hits |
| Queue depth after fan-out | Nightly storms |
Heartbeat pattern:
# 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
| Tool | Use when |
|---|---|
| Celery Beat | Already on Celery + need in-process schedules |
| RedBeat | Need dynamic schedules + multi-instance Beat with Redis lock |
| K8s CronJob | Platform schedules HTTP/enqueue; fewer moving parts in app |
| Taskiq scheduler | Taskiq stack instead of Celery |
| APScheduler in API | Avoid for multi-replica APIs (duplicate fires) |
| Kafka + time windows | Stream processing, not simple cron |
APScheduler embedded in each API replica is the FastAPI analogue of “accidentally multi-Beat.”
---
11. Anti-patterns
| Anti-pattern | Fix |
|---|---|
| Beat + worker + API in one container | Split processes |
replicas: 3 on Beat, no lock | Single leader or RedBeat |
| Heavy CPU inside Beat process | Beat only dispatches |
| Non-idempotent daily charge task | Unique period key + lock |
| Local TZ crons without DST review | UTC + explicit business rules |
| Silent miss after Beat outage | Reconcile commands + alerts |
| Schedule args with secrets | Settings/vault in worker |
---
12. Checklist
- [ ] Beat is a separate deployable from API and workers
- [ ] Exactly one leader (replicas=1 or distributed lock)
- [ ]
timezonedocumented (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
# 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# 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
- 05 Celery + RabbitMQ
- 03 Job lifecycle
- 09 Errors / retries
- 13 Laravel queues map (schedule ≈ cron / Task scheduling)