0 0 * * * Meaning — What Daily-at-Midnight Really Does (and When It Won't)

0 0 * * * = every day at 00:00 — the server's midnight, not yours. The timezone catch, the midnight-DST edge, thundering-herd history, and the look-alikes.

Published 2026-09-29

0 0 * * * is probably the most-deployed cron line in existence — and the most misread. Field by field:

0  minute       → :00
0  hour         → hour 0 (midnight, not noon)
*  day of month → every day of the month
*  month        → every month
*  day of week  → every weekday — nothing to OR with, so no trap

At 00:00 every day — once per day at local midnight, 365 times a year. date would call it 00:00; @daily and @midnight are nicknames for the identical expansion.

Whose midnight?

The first thing to check isn’t the expression — it’s the timezone. Cron schedules in the machine’s local time (or CRON_TZ= where the daemon supports it). A 0 0 * * * on a UTC server fires at 00:00 UTC, which is 08:00 in Beijing and yesterday’s 19:00 in New York. If the schedule is meant to hit your midnight, either the server’s clock or a CRON_TZ line has to say so — this is the #1 reason “my midnight job ran at the wrong time” reports happen.

Platform notes worth knowing:

  • GitHub Actions schedule: cron is always UTC — no local time, no CRON_TZ.
  • Kubernetes CronJob schedules in the controller’s timezone; spec.timeZone: exists (stable since v1.27) to pin it.
  • Quartz-family dialects write midnight as 0 0 0 * * ? (with a seconds field) or cron(0 0 * * ? *) on AWS EventBridge — same meaning, different grammar.

The midnight-specific edge cases

Midnight is where DST transitions sometimes sit. Most zones switch at 01:00–03:00, but several put the jump at or next to local midnight — Palestine, Chile, Cuba, Egypt and others have used 00:00-transition rules. When 00:00 doesn’t exist (spring forward) a 0 0 * * * job is skipped or runs late; when 00:00 happens twice (fall back) it may fire twice — the exact behavior is implementation-defined per daemon. If the job can’t tolerate a missed or doubled run, 02:00–04:00 is boring on purpose: 17 3 * * * survives nearly every DST rule ever legislated. The calculator flags skipped local times rather than printing a fantasy.

Missed midnights don’t queue. Machine off at 00:00 → that run never happened. Classic cron has no catch-up; that’s what anacron and Persistent=true timers exist for.

The thundering herd is real. Midnight is the world’s default cron minute — log rotation, backup kicks, batch windows all landed on 0 0 for decades. Distros responded by moving their own daily jobs off it (Debian’s cron.daily sits at 06:25; RHEL-family routes through anacron’s randomized window). If your midnight job hits an API, a database or an NFS share shared with other boxes, a random minute like 43 2 * * * is the kind thing to do.

If not midnight, when?

A daily job doesn’t owe anyone the round hour — it owes you a quiet, unambiguous slot. The practical picks:

  • Pick an odd minute. 17 3 * * * spreads load away from the :00 pile-up that every tutorial taught everyone else to use.
  • Dodge 01:00–03:59 local. That’s where most of the world’s DST legislation lives — the US jumps at 02:00 local, EU zones shift at 01:00 UTC (which lands on 02:00/03:00 local). A 04:00 or 06:00 job essentially never meets a transition.
  • Match the business clock. “Daily” for a report should mean after the data exists — run after the upstream pipeline’s worst-case finish plus slack, not at the calendar boundary.
  • Pin the timezone where it’s supported. A CRON_TZ=America/New_York line above the entry makes “midnight” mean the right midnight even when the server runs UTC.

The look-alikes people actually type

expression what it means the confusion
0 0 * * * daily at 00:00 —
* 0 * * * every minute 00:00–00:59 minute * ≠ minute 0 — 60 runs, not 1
0 12 * * * daily at noon hour is 0–23; 12 = midday, not midnight
0 0 * * 1-5 midnight on weekdays day-of-week restricted, dom * — fine, no OR trap
0 0 * * 0 Sunday midnight 0 is Sunday, not “end of week” abstractly
0,0 0 * * * daily at 00:00 0,0 dedupes to 0 — harmless but noisy
@daily daily at 00:00 nickname for the same line

And the one that comes up in migration threads: 0 0 * * * pasted into a Quartz or EventBridge field is a syntax error there (they want 0 0 0 * * ? / cron(0 0 * * ? *)), while their six-field line pasted into a crontab is a syntax error here. Same words “daily at midnight”, different dialects — paste either into the calculator and it will tell you which grammar you’re holding.

Verify before it matters

0 0 * * * is exactly the kind of schedule that’s “obviously right” until a DST weekend or a UTC server proves otherwise. Check the next-runs list above against your server’s timedatectl output, and if the run still doesn’t happen when it should, the checklist in debugging cron covers the environmental failures — % escaping, minimal PATH, missing cwd — that a correct expression can’t save you from.

Frequently asked questions

Does 0 0 * * * mean midnight or noon?

Midnight — hour 0, minute 0, in 24-hour time. Noon is 0 12 * * *. The hour field is 0–23 with no AM/PM, so 0 is the start of the day and 12 is midday.

Whose midnight does 0 0 * * * use?

The machine's local timezone — whatever timedatectl or the container's /etc/localtime says. Some daemons honor a CRON_TZ= line above the entry. GitHub Actions schedule: cron is always UTC; Kubernetes CronJobs default to the controller's timezone (a spec.timeZone field, GA since v1.27, can pin it). Your browser showing 00:00 predictions is showing your zone — shift mentally or set CRON_TZ.

Is 0 0 * * * the same as @daily?

Exactly the same — @daily and @midnight both expand to 0 0 * * *. The nicknames exist because daily-at-midnight is the most common schedule in the world.

If the server is off at midnight, does the job run at boot?

No — classic cron has no memory. A 00:00 firing missed while powered down is simply gone; there's no queue. For catch-up behavior you want anacron, cron.daily, or a systemd timer with Persistent=true — compared in systemd timers vs cron.

What's the Quartz/6-field equivalent?

0 0 0 * * ? — seconds, minute, hour, day-of-month, month, ? for day-of-week. AWS EventBridge writes it cron(0 0 * * ? *) with a year field. Different dialect entirely — this site's parser is standard 5-field and says so when it meets the others.

Why not run everything at midnight?

Because everyone does. Distros moved their own daily jobs off 00:00 years ago to avoid the pile-up — Debian's cron.daily defaults to 06:25, RHEL-family runs daily jobs through anacron with a randomized window. For your own jobs, any odd minute like 17 3 * * * is usually the better midnight.