Cron Schedules in GitHub Actions: UTC, Delays, Pitfalls
A scheduled workflow looks simple, but several rules surprise people: times are in UTC, runs can start late or be skipped, and the schedule only works from the default branch. This guide covers the syntax and the behaviour you need to plan around.
The syntax
on:
schedule:
- cron: "30 5 * * 1-5" # 05:30 UTC, Monday to Friday
- cron: "0 */6 * * *" # every 6 hours, on the hour
workflow_dispatch: # lets you run it by hand too
A cron expression has five fields:
minute (0-59)
| hour (0-23)
| | day of month (1-31)
| | | month (1-12 or JAN-DEC)
| | | | day of week (0-6 or SUN-SAT)
| | | | |
* * * * *
Supported operators: (any), , (list), - (range), / (step). GitHub uses POSIX cron syntax. Extensions from other systems, like @daily, L, ? or a seconds field, do not work. Quote the expression in YAML, because a leading is otherwise parsed as a YAML alias and the file is invalid.
The cron builder converts plain-language schedules to expressions and shows the next run times, which is the easiest way to avoid mistakes.
Everything is UTC
GitHub evaluates schedules in UTC. There is no time zone option and no daylight saving adjustment. A job set for 0 9 * * * runs at 09:00 UTC, which is 05:00 in New York in summer, 04:00 in winter. If you need "09:00 local, all year", you have two options:
- Schedule both UTC hours that could match and exit early in the one that is wrong:
on:
schedule:
- cron: "0 13 * * 1-5" # 09:00 EDT
- cron: "0 14 * * 1-5" # 09:00 EST
jobs:
report:
runs-on: ubuntu-latest
steps:
- name: Only proceed at 09:00 New York time
id: gate
run: |
hour=$(TZ=America/New_York date +%H)
[ "$hour" = "09" ] && echo "go=true" >> "$GITHUB_OUTPUT" || echo "go=false" >> "$GITHUB_OUTPUT"
- if: steps.gate.outputs.go == 'true'
run: ./send-report.sh
- Accept a one-hour shift twice a year and say so in a comment.
Schedules are not exact
GitHub's docs say that scheduled workflows can be delayed during periods of high load, and that high-load times include the start of every hour. In practice, 0 * * * * jobs commonly start several minutes late, and occasionally runs are dropped under heavy load. So:
- Avoid the top of the hour. Use odd minutes such as
17 3 * * *. This is the simplest way to reduce delay. - Do not rely on exact timing. A workflow that must run at precisely 09:00 should be triggered by something else, such as a cloud scheduler calling the
workflow_dispatchAPI. - Make jobs idempotent. Use the time they process (a window computed from the run's event time) rather than "now minus 1 hour", so a late or repeated run produces the same result.
- Minimum interval: the shortest interval GitHub supports is every 5 minutes. For anything more frequent, use a different trigger or a long-running service.
Where schedules run
- A scheduled workflow runs on the latest commit of the default branch. The workflow file on other branches is ignored for scheduling. To test a change, push it to the default branch or run the workflow manually with
workflow_dispatchagainst your branch. - Scheduled runs use the repository's default branch for
GITHUB_REF; checkout therefore gets the default branch unless you specify otherwise. - The actor is the user who last modified the cron line in the file. If that user is removed from the organization, runs may stop working; commit a trivial edit to the cron line under a service account if needed.
The 60-day rule
In public repositories, GitHub automatically disables scheduled workflows after 60 days with no repository activity. You get an email and must re-enable it, either in the Actions UI or with:
gh workflow enable schedule-workflow.yml
Private repositories are not affected by this rule, according to GitHub's docs. If you depend on a schedule in a quiet public repository, add a step that keeps it alive, for example a periodic commit by a bot, or use an external scheduler.
Common mistakes
| Mistake | Result | Fix |
|---|---|---|
Unquoted /5 * * * | YAML parse error | Quote the string |
| Assuming local time | Runs hours off, shifts with DST | Convert to UTC |
0 0 31 * * | Runs only in months that have 31 days | Use 0 0 1 * * or compute last day in the job |
| Day of month and day of week both set | They combine with OR, not AND (POSIX behavior) | Set one, and check inside the job for the other |
| Testing on a feature branch | Schedule never fires | Merge to default or use workflow_dispatch |
| Heavy hourly job at minute 0 | Delayed starts, resource spikes | Use odd minutes |
| Forgetting concurrency | Overlapping runs if one is slow | Add concurrency with a group |
Add a concurrency group so a slow run does not overlap with the next:
concurrency:
group: nightly-report
cancel-in-progress: false
Cost of schedules
Schedules are billed like any other run, including failed ones. A 5-minute job every 15 minutes is 480 billed minutes a month on Linux, about $2.88 at $0.006 a minute, and it adds up across many repositories. Cron jobs that poll for something can often be replaced by event triggers (push, release, repository_dispatch, webhooks). Also use timeout-minutes, and put a cheap pre-check first, so the job exits in seconds when there is nothing to do. See reducing your GitHub Actions bill and estimate usage with the Actions cost calculator.
Good uses of schedules
- Nightly full test runs and flaky-test hunts (see dealing with flaky tests).
- Dependency and security scans.
- Cache warmers for the default branch, so PRs start with a warm cache (caching guide).
- Stale issue cleanup and report generation.
FAQ
Can I schedule in my time zone?
Not directly. Convert to UTC, or schedule two UTC times and gate on local time as shown above.
Why did my schedule run late or skip a run?
GitHub warns that load can delay scheduled runs, especially at the start of the hour. Use off-peak minutes, and design for tolerance.
Can I have multiple schedules in one workflow?
Yes, list several cron entries. Use github.event.schedule to tell which one fired.
How do I run the same workflow manually?
Add workflow_dispatch: and use "Run workflow" in the Actions tab or gh workflow run.
Try it
Use the free cron builder to check the next run times before you commit. If scheduled jobs are heavy, compiler.dev's comparison mode shows what the same nightly job costs on faster runners.
Made by compiler.dev. Free tools · Pricing