In Celery, crontab() from celery.schedules takes five arguments that mirror the cron fields: minute, hour, day_of_week, day_of_month and month_of_year. Every argument you leave out defaults to "*", so always set minute explicitly. Every 5 minutes is crontab(minute="*/5"), daily at 09:00 is crontab(minute=0, hour=9), and weekdays at 09:00 is crontab(minute=0, hour=9, day_of_week="mon-fri"). Schedules run in the app's timezone setting, which is UTC unless you change it.
Everything below was checked against Celery 5.6.3, the current stable release.
Quick answer: cron string to crontab()
Schedule Cron string crontab() call
Every minute * * * * * crontab()
Every 5 minutes */5 * * * * crontab(minute="*/5")
Every 15 minutes */15 * * * * crontab(minute="*/15")
Hourly at :00 0 * * * * crontab(minute=0)
Every 6 hours 0 */6 * * * crontab(minute=0, hour="*/6")
Daily at midnight 0 0 * * * crontab(minute=0, hour=0)
Daily at 09:00 0 9 * * * crontab(minute=0, hour=9)
Daily at 09:00 and 17:00 0 9,17 * * * crontab(minute=0, hour="9,17")
Weekdays at 09:00 0 9 * * 1-5 crontab(minute=0, hour=9, day_of_week="mon-fri")
Every Monday at 07:30 30 7 * * 1 crontab(minute=30, hour=7, day_of_week=1)
Sundays at 03:00 0 3 * * 0 crontab(minute=0, hour=3, day_of_week="sun")
1st of month at 00:00 0 0 1 * * crontab(minute=0, hour=0, day_of_month=1)
1st of each quarter 0 0 1 */3 * crontab(minute=0, hour=0, day_of_month=1, month_of_year="*/3")
Every 30 min, 9-17 M-F */30 9-17 * * 1-5 crontab(minute="*/30", hour="9-17", day_of_week="mon-fri")The last row fires from 09:00 through 17:30, because the hour range includes 17.
How crontab() maps to cron fields
A cron string has five fields in this order: minute, hour, day of month, month, day of week. The crontab() signature uses a different order:
crontab(minute="*", hour="*", day_of_week="*", day_of_month="*", month_of_year="*")That difference is why you should always pass keyword arguments. crontab(0, 0, 1) looks like "midnight on the 1st", but the third positional argument is day_of_week, so it actually means every Monday at midnight.
Each field accepts an integer, a set or list of integers, or a cron-style pattern string such as "*/15", "8-17", "0,30" or "1-7,15-21". Valid ranges are 0 to 59 for minutes, 0 to 23 for hours, 0 to 6 for day of week, 1 to 31 for day of month and 1 to 12 for months. Out-of-range values raise a ValueError as soon as the schedule is created.
If you already have a cron string, crontab.from_string() converts it directly:
from celery.schedules import crontab
schedule = crontab.from_string("*/30 9-17 * * 1-5")It splits on single spaces, so a double space raises an error, and macros like @daily are not supported. When you want to build or check the expression first, the free cron expression generator on this site turns any cron schedule into a Celery crontab() call.
Celery beat crontab examples in code
Define periodic tasks in beat_schedule:
from celery import Celery
from celery.schedules import crontab
app = Celery("proj", broker="redis://localhost:6379/0")
app.conf.timezone = "America/New_York"
app.conf.beat_schedule = {
"sync-orders-every-5-min": {
"task": "orders.tasks.sync_orders",
"schedule": crontab(minute="*/5"),
},
"weekday-sales-report": {
"task": "reports.tasks.send_sales_report",
"schedule": crontab(minute=0, hour=9, day_of_week="mon-fri"),
"args": ("sales",),
},
"monday-cleanup": {
"task": "maintenance.tasks.purge_old_files",
"schedule": crontab(minute=30, hour=7, day_of_week=1),
},
"month-start-invoices": {
"task": "billing.tasks.generate_invoices",
"schedule": crontab(minute=0, hour=0, day_of_month=1),
},
}Two details from the docs: task is the registered task name, not the import path, and a single-item args tuple needs the trailing comma. A few more patterns worth keeping nearby:
crontab(minute="*/10", hour="9-17") # every 10 min, 09:00 to 17:50
crontab(minute=0, hour=12, day_of_week="sat,sun") # weekends at noon
crontab(minute=0, hour=0, day_of_month="1-7,15-21") # first and third week
crontab(minute=0, hour=0, day_of_month=11, month_of_year=5) # May 11 each yearCelery crontab time zones
Crontab schedules are evaluated in the app's timezone setting. It defaults to UTC, and enable_utc is on by default. If you leave timezone unset and turn enable_utc off, Celery falls back to the server's local time zone.
Set the zone explicitly instead:
app.conf.timezone = "America/New_York"Now crontab(minute=0, hour=9) means 09:00 New York time, and it stays at 09:00 local across daylight saving changes. A quick check in Celery 5.6.3 computes the run after the November 1, 2026 clock change as 09:00 EST, which is 14:00 UTC instead of the 13:00 UTC it was the day before. If you hardcode UTC hours instead, your local run time shifts by an hour twice a year.
Also note:
crontab()has no per-schedule time zone argument. Passingtz=raises aTypeError, so every crontab entry in an app shares one zone.- In Django projects, Celery uses Django's
TIME_ZONEwhen no Celery time zone is set. - The default scheduler notices a time zone change and resets itself. The django-celery-beat database scheduler does not, so the docs tell you to run
PeriodicTask.objects.update(last_run_at=None)after changing it.
GitHub Actions handles this differently; see GitHub Actions cron time zones.
Traps that cause missing or duplicate runs
Unset fields mean every value
crontab(hour=7) does not run once at 07:00. minute defaults to "*", so it runs 60 times, every minute from 07:00 to 07:59. Write crontab(minute=0, hour=7).
Steps restart every hour or day
minute="*/7" expands to 0, 7, 14 ... 56 and then starts again at 0, so there is a 4 minute gap at the top of each hour. Likewise hour="*/5" fires at 00:00, 05:00, 10:00, 15:00 and 20:00. If you need an exact gap, use an interval schedule.
day_of_week numbering
Sunday is 0 and Saturday is 6. Celery rejects 7 with a ValueError, even though cronie accepts 7 as Sunday. Names such as "sun" and "mon-fri" avoid the confusion. Also, day_of_week="*/2" means days divisible by two (Sunday, Tuesday, Thursday, Saturday), not every other day.
Day fields are combined with AND
Celery runs on a day only if day_of_week, day_of_month and month_of_year all match. Standard cron (cronie) runs when either restricted day field matches. So crontab(minute=0, hour=0, day_of_month=1, day_of_week=1) runs only when the 1st falls on a Monday, not on every 1st plus every Monday.
Run exactly one beat
The docs require a single scheduler per schedule at a time; otherwise tasks are duplicated. Every worker started with -B embeds its own beat, which is why the docs call -B fine for a single worker node but not recommended for production. Run one celery -A proj beat process.
Missed runs after downtime
With beat_cron_starting_deadline at its default of None (setting added in 5.3), a crontab entry that is past due runs immediately when beat checks it. Set it to a number of seconds to skip runs that are too old. The docs discourage values above 3600.
Testing a Celery crontab schedule
You can ask a schedule when it fires next without waiting for it:
from celery import Celery
from celery.schedules import crontab
app = Celery("proj")
app.conf.timezone = "America/New_York"
sched = crontab(minute=0, hour=9, day_of_week="mon-fri", app=app)
now = app.now()
print(sched) # <crontab: 0 9 * * mon-fri (m/h/dM/MY/d)>
print(sched.remaining_estimate(now)) # timedelta until the next run
print(sched.is_due(now)) # schedstate(is_due=False, next=...)is_due() returns a pair: whether the task is due now, and how many seconds until the next check.
Then confirm end to end. Temporarily set the entry to crontab() (every minute) and start beat with a throwaway schedule file:
celery -A proj beat -l info -s ./test-scheduleWithin a minute, look for a log line like Scheduler: Sending due task sync-orders-every-5-min (orders.tasks.sync_orders). The -s flag keeps test state out of the default celerybeat-schedule file, which beat writes in its working directory.
In production, alert on missing runs as well as failed ones, because a schedule that silently stops firing is easy to miss. The monitoring and observability blueprint covers that setup.
When to use interval or solar schedules instead
Crontab's smallest unit is one minute and it always aligns to the clock. For anything else, schedule also accepts a number of seconds or a timedelta. The docs note that a 30 second timedelta sends the first task 30 seconds after beat starts, then every 30 seconds after the last run.
For events tied to daylight, Celery has solar(), which needs the ephem package installed:
from datetime import timedelta
from celery.schedules import solar
app.conf.beat_schedule = {
"poll-queue-every-45s": {
"task": "jobs.tasks.poll_queue",
"schedule": timedelta(seconds=45),
},
"sunset-new-york": {
"task": "jobs.tasks.lights_on",
"schedule": solar("sunset", 40.7128, -74.0060),
},
}Supported events include sunrise, solar_noon, sunset and the civil, nautical and astronomical dawn and dusk variants. Latitude and longitude are positive for north and east, negative for south and west, and solar events are calculated in UTC regardless of your timezone setting.
Use crontab for calendar rules ("weekdays at 09:00"), intervals for steady polling, and solar for daylight. For a real example of daily scheduled checks with retries, see the FastAPI price monitoring microservice case study.
Need scheduled jobs built?
If you need periodic tasks that run on time, in the right time zone, and alert you when they stop, that is part of my Python backend and API development work: Celery workers and beat, retries, monitoring and deployment.
