Backend Engineering

Celery Crontab Examples: Every Common Schedule as a crontab() Call

Celery crontab examples for every common schedule, mapped from cron strings to crontab() calls, plus time zones, day_of_week numbering and beat traps.

Muhammad Yahya, authorMuhammad Yahya7 min read

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 year

Celery 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. Passing tz= raises a TypeError, so every crontab entry in an app shares one zone.
  • In Django projects, Celery uses Django's TIME_ZONE when 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-schedule

Within 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.

Frequently asked questions

How do I run a Celery task every 5 minutes?

Use crontab(minute='*/5') as the schedule. It fires at :00, :05, :10 and so on in the app's time zone. If you want every 5 minutes counted from when beat starts instead of aligned to the clock, use timedelta(minutes=5).

What time zone does Celery beat use for crontab schedules?

The app's timezone setting, which defaults to UTC. Set app.conf.timezone to a name such as America/New_York to schedule in local time. crontab() has no time zone argument of its own.

Is Sunday 0 or 7 in Celery's day_of_week?

Sunday is 0 and Saturday is 6. Celery rejects 7 with a ValueError, even though cron implementations such as cronie accept 7 as Sunday. You can also use names like 'sun' or ranges like 'mon-fri'.

Why does my Celery periodic task run twice?

Check how many beat schedulers are running. Celery requires a single scheduler per schedule, so a standalone beat process plus a worker started with -B, or two beat processes, will send every task twice.

Related reading

Muhammad Yahya

Written by Muhammad Yahya

Python Automation & Backend Developer. Top Rated on Upwork with a 100% Job Success Score and 5+ years building automations, AI workflows and production backends.

Hire Me on Upwork

More articles