GitHub Actions cron schedules run in UTC by default, but you no longer have to convert by hand: since March 2026, GitHub accepts a timezone key next to each cron line, set to an IANA name such as America/New_York. If a scheduled workflow runs late or not at all, the usual causes are high load at the start of the hour, a workflow file that is not on the default branch, or a public repository with no activity for 60 days, which switches schedules off automatically.
The short answer
- Time zone: cron is read as UTC unless you add
timezone. GitHub announced it in the March 19, 2026 changelog, so older answers saying time zones are not supported are out of date. - Delays: runs can start late under high load, especially at the start of every hour, and some queued jobs may be dropped.
- Missed runs: schedules run only from the default branch, at most every 5 minutes, and stop in quiet public repositories after 60 days.
How GitHub Actions cron works
A schedule lives under on.schedule as a list of cron entries. Each uses POSIX cron syntax with five fields:
minute hour day-of-month month day-of-week
15 4 * * 1-5That example means 04:15 UTC, Monday to Friday. Fields accept * for any value, , for lists, - for ranges and / for steps. Months can be 1 to 12 or JAN to DEC, and days of the week 0 to 6 or SUN to SAT.
A few rules catch people out:
- Shortcuts such as
@dailyand@hourlyare not supported. - The shortest interval is once every 5 minutes.
- Without a
timezonekey, every field is evaluated in UTC. - A workflow can have several cron entries, and
github.event.scheduletells you which one fired.
The free cron expression generator on this site previews the next run times for an expression and converts a schedule into GitHub Actions YAML.
Setting a time zone
Add timezone alongside cron in the same list item. The value is an IANA time zone name such as America/Chicago or Europe/London, not an abbreviation like EST:
name: Weekday report
on:
schedule:
- cron: "15 9 * * 1-5"
timezone: "America/New_York"
workflow_dispatch:
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: python3 scripts/weekday_report.pyThis runs at 9:15 AM New York time on weekdays, all year, with no edits when the clocks change. The key belongs to the individual cron entry, not the whole workflow.
What happens at daylight saving time
GitHub documents one edge case: on the spring-forward night, a schedule inside the skipped hour advances to the next valid time, so a 2:30 AM job runs at 3:00 AM. The docs do not describe the fall-back night, when 1:00 to 1:59 AM happens twice in US time zones. The simple fix is to keep important jobs out of the 1:00 to 3:00 AM local window.
If you keep the schedule in UTC
Without timezone, you convert local time yourself, and daylight saving time changes the answer. For a report due at 9:15 AM New York time:
- From March 8 to October 31, 2026, New York is on EDT (UTC-4), so the cron is
15 13 * * 1-5. - From November 1, 2026, New York is on EST (UTC-5), so the cron becomes
15 14 * * 1-5.
So a UTC cron drifts by an hour in local time twice a year unless someone edits it. Setting timezone removes that chore.
Why scheduled runs are delayed or skipped
These causes come from GitHub's documentation:
- Load at the start of the hour. The
scheduleevent can be delayed during high load, and the start of every hour is one such time. If the load is high enough, some queued jobs may be dropped, so that run never happens. - The file is not on the default branch. Scheduled workflows run only on the default branch, using its latest commit. A schedule added on a feature branch does nothing until it is merged.
- 60 days without activity. In a public repository, scheduled workflows are disabled automatically when no repository activity has occurred in 60 days.
- Forks. When a public repository is forked, scheduled workflows in the fork are disabled by default.
- Unsupported syntax.
@dailystyle shortcuts do not work, and nothing runs more often than every 5 minutes. - An inactive actor. With Enterprise Managed Users, scheduled workflows stop if the user account tied to the workflow is suspended, deleted or deprovisioned.
Every scheduled run is attributed to a user, the actor. Notifications go to the user who created the workflow, then to whoever last changed the cron syntax or re-enabled a disabled workflow. If that person leaves, failure emails may reach an inbox nobody reads.
A debugging checklist
- 1Confirm the workflow file is on the default branch, and that you edited that copy.
- 2Run
gh workflow list --allor open the Actions tab to see whether the workflow was disabled. Re-enable it withgh workflow enable. - 3Run
gh run list --workflow report.yml --event scheduleto see whether runs started late or never started. - 4Read the cron fields in order: minute, hour, day of month, month, day of week.
- 5Without a
timezonekey, convert the expected local time to UTC before deciding a run is late. - 6Check that
timezoneis a valid IANA name, indented at the same level ascron. - 7Trigger the workflow by hand with
gh workflow run report.yml(this needsworkflow_dispatch) to separate scheduling problems from job failures. - 8If the run starts but fails, it is not a cron problem. Read the job logs.
Good practices for scheduled workflows
- Avoid minute 0. GitHub suggests a different time of the hour to reduce delays.
17 6 * * *is safer than0 6 * * *. - Add a manual trigger.
workflow_dispatchgives you a Run workflow button for testing. - Prevent overlaps. With
concurrencyand a fixed group name, only one run is in progress at a time. A new run waits as pending, and a newer one replaces it. - Set a timeout. The default job timeout is 360 minutes, so set
timeout-minutesto something realistic. - Alert on failure. Turn on Actions notifications for failed runs, or add a final step with
if: failure()that posts to your team chat. - Watch for runs that never start. A failure alert cannot fire for a dropped run. For jobs that matter, ping a heartbeat monitor that alerts you when the ping is missing.
Put together:
name: Nightly sync
on:
schedule:
- cron: "17 6 * * *"
timezone: "Europe/London"
workflow_dispatch:
concurrency:
group: nightly-sync
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- name: Run the sync
run: python3 scripts/sync.py
- name: Report failure
if: failure()
run: python3 scripts/notify_failure.pyHere notify_failure.py stands in for whatever posts to your chat, with its webhook URL passed in from a repository secret.
When to move scheduling elsewhere
GitHub Actions cron suits repository chores: nightly tests, dependency checks and small reports. It is a weaker fit when a job must start on the minute or more often than every 5 minutes, when a missed run causes real damage such as unsent invoices, or when the job needs your production database or private network.
- Server cron is the simplest option, running next to the code and data it needs.
- Celery beat suits apps that already run Celery workers, with a time zone setting and task retries. See these Celery crontab examples.
- APScheduler fits a single Python service that needs in-process scheduling, with cron-style triggers and time zone support.
Many teams use both: GitHub Actions for CI, as in this CI/CD pipeline for Python backends, and an application scheduler for business jobs such as a scheduled Excel report.
Next steps
Set the time zone explicitly, avoid minute 0, keep a manual trigger and alert on both failures and missing runs. If your scheduled jobs have grown into business processes that need retries and monitoring, my business process automation page explains how I build them to run reliably.
