skip to content

In Celery, how do the timezone and enable_utc settings decide when a beat entry with crontab(hour=2, minute=0) fires, and what must change afterwards?

level: middleimportance: should knowfreq 35%

answer

  1. whose clock does crontab read
  2. unset timezone falls back
  3. enable_utc picks UTC or local
  4. the schedule file remembers tz

basics

~20 s

crontab fields are matched against the wall clock of the app's timezone; with timezone unset and enable_utc True, that is UTC, so hour=2 means 02:00 UTC. PersistentScheduler resets itself when a stored zone changes; DatabaseScheduler needs a manual reset.

solid answer

~40 s

Celery works out one app time zone: the `timezone` setting if it is set, otherwise UTC while `enable_utc` is `True` (the default), otherwise the host's local time zone. Beat reads the current time in that zone, and `crontab` matches its fields against that wall clock, so `crontab(hour=2, minute=0)` fires at 02:00 UTC by default and at 02:00 Berlin time once `timezone = 'Europe/Berlin'` is set. `solar` schedules are computed in UTC and ignore the setting. The default `PersistentScheduler` stores the `timezone` and `enable_utc` values in its `celerybeat-schedule` file and clears its stored schedule when either differs at start-up, though a previously unset `timezone` stores nothing to compare, so setting it for the first time goes undetected. django-celery-beat's `DatabaseScheduler` does not, so you reset `last_run_at` on the `PeriodicTask` rows yourself.

code

python · 14 lines
python
from celery import Celery
from celery.schedules import crontab

app = Celery("saas", broker="redis://localhost:6379/0")

# Without this line, hour=2 below means 02:00 UTC.
app.conf.timezone = "Europe/Berlin"

app.conf.beat_schedule = {
    "nightly-billing": {
        "task": "billing.tasks.run_nightly_billing",
        "schedule": crontab(hour=2, minute=0),  # 02:00 Berlin wall clock
    },
}

go deeper

for a junior

Recall that an unset timezone with the default enable_utc means crontab hours are UTC hours.

for a middle

Explain the three-way resolution of timezone and enable_utc, and which schedule types read the wall clock.

for a senior

Plan the change: reset or confirm the reset of stored last-run times per scheduler, and keep billing clear of daylight-saving gaps.

for a principal

Choose between a fixed UTC instant and a local wall-clock time for customer-facing jobs, and document it as policy.

## Why a nightly job runs at the "wrong" hour In the reserved scenario a SaaS schedules its **nightly billing run** with `crontab(hour=2, minute=0)`, intending 02:00 for its European customers. Invoices go out at 03:00 or 04:00 local time instead, depending on the season. Nothing is broken: the entry fired at 02:00 in the **app's time zone**, which nobody set. ## How Celery picks the app time zone Two settings decide it, and the rule is in the app's `timezone` property: | `timezone` | `enable_utc` | App time zone | |---|---|---| | set, e.g. `'Europe/Berlin'` | any | That zone (any name `zoneinfo` knows) | | unset | `True` (the default) | UTC | | unset | `False` | The host's local time zone | A note on defaults: the documentation lists `timezone`'s default as `"UTC"`, while the settings table in the source leaves it unset; the effective result is the same, because an unset `timezone` with `enable_utc=True` resolves to UTC. `enable_utc` also governs how dates inside messages, such as `eta` and `expires`, are converted. ## How beat applies it to schedules Beat asks the app for the current time, which Celery returns converted into the app time zone. Each schedule type then uses that clock differently: - **`crontab`** matches its minute, hour, day and month fields against the **wall clock in the app time zone**. `hour=2` means 02:00 on that zone's clock. - **`timedelta` or a number of seconds** measures elapsed time since the last send, so the time zone does not move it. - **`solar`** events are calculated in UTC and are unaffected by the `timezone` setting, per Celery's guide. So the fix for the billing job is one of two deliberate choices: 1. Set `app.conf.timezone = 'Europe/Berlin'` and keep `crontab(hour=2, minute=0)`: billing follows Berlin's wall clock across summer and winter time. 2. Keep UTC and write the hour in UTC: the run stays at a fixed instant and its local hour shifts with daylight saving. A time zone with daylight saving has one night a year where 02:00 is skipped and one where an hour repeats. Treat that edge as something to test rather than assume; scheduling billing outside the transition hours, or in UTC, avoids depending on it. ## What must happen after you change the settings Beat stores each entry's **last send time**, and those stored times were recorded under the old zone. The two common schedulers react differently: | Scheduler | On a `timezone` or `enable_utc` change | |---|---| | `celery.beat:PersistentScheduler` (default) | Stores `tz` and `utc_enabled` in `celerybeat-schedule`; at start-up it logs `Reset: Timezone changed from ... to ...` (or `Reset: UTC changed ...`) and clears the stored schedule. A `tz` stored as unset is not compared, so the first time you set `timezone` there is no reset | | `django_celery_beat.schedulers:DatabaseScheduler` | Does **not** reset; stale `last_run_at` values stay in the `PeriodicTask` rows | For the database scheduler, Celery's guide says to reset the rows yourself. Because a bulk `QuerySet.update()` fires no model signals, also bump django-celery-beat's change marker so a running beat reloads: ```python from django_celery_beat.models import PeriodicTask, PeriodicTasks PeriodicTask.objects.update(last_run_at=None) PeriodicTasks.update_changed() ``` ## Where per-row time zones fit django-celery-beat's `CrontabSchedule` model carries its own `timezone` field, whose default comes from the Celery time zone setting (or UTC). That lets one schedule fire at 07:00 New York time while another fires at 07:00 Tokyo time, from a single beat process. The app-wide `timezone` remains the zone for plain `beat_schedule` crontabs. ## Diagnosing a schedule that fires at the wrong hour - In a shell, print `app.timezone` and `app.now()` for the same app beat loads; `app.now()` returns the current time already converted into the app time zone, which is the clock `crontab` is matched against. - Read beat's log at `INFO`: each send is logged as `Scheduler: Sending due task <entry> (<task>)` with a timestamp, so compare those timestamps with the hour you intended. - Check which scheduler class is in use (`beat_scheduler` or the `-S` flag), because it decides whether a zone change resets stored state automatically. - For django-celery-beat rows, look at the row's own `CrontabSchedule.timezone` before blaming the app setting. ## Checklist - Decide the zone explicitly: set `timezone`, and leave `enable_utc` at `True` unless you have a reason. - Write every `crontab` hour in that zone, and say so in a comment. - After changing either setting, confirm the `Reset:` log line (file scheduler; if it is absent because `timezone` was previously unset, delete the state file deliberately) or reset `last_run_at` (database scheduler). - Avoid relying on hours that daylight-saving changes skip or repeat.

  • Does the `timezone` setting shift a Celery beat entry whose schedule is `timedelta(hours=1)`?
    No. A `timedelta` schedule measures elapsed time since the entry was last sent, so an hourly warm-up stays hourly whatever the zone. Only wall-clock schedules, `crontab` above all, read the app time zone.
  • After changing Celery's `timezone`, why can a django-celery-beat schedule still fire at odd times until you intervene?
    `DatabaseScheduler` keeps each `PeriodicTask`'s `last_run_at` from before the change and does not reset it. Clear those values, for example `PeriodicTask.objects.update(last_run_at=None)`, then call `PeriodicTasks.update_changed()` so a running beat reloads, since a bulk update fires no model signals.

saying these in an interview costs you the question

  • crontab hours are read in the beat host's local time zone by default
  • enable_utc=True forces UTC even when timezone is set
  • The timezone setting also shifts timedelta schedules
  • DatabaseScheduler resets last-run times itself when timezone changes
  • solar schedules follow the timezone setting