Skip to content
compiler.dev

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_dispatch API.
  • 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_dispatch against 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

MistakeResultFix
Unquoted /5 * * *YAML parse errorQuote the string
Assuming local timeRuns hours off, shifts with DSTConvert to UTC
0 0 31 * *Runs only in months that have 31 daysUse 0 0 1 * * or compute last day in the job
Day of month and day of week both setThey combine with OR, not AND (POSIX behavior)Set one, and check inside the job for the other
Testing on a feature branchSchedule never firesMerge to default or use workflow_dispatch
Heavy hourly job at minute 0Delayed starts, resource spikesUse odd minutes
Forgetting concurrencyOverlapping runs if one is slowAdd 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