Why your crontab is off by 8 hours

A cron expression carries no timezone. The line 0 9 * * * does not mean "9 a.m." in any absolute sense — it means "9 a.m. in whatever timezone the cron daemon runs in." On a UTC server that is 17:00 in UTC+8, eight hours from what you intended.

This is the most common crontab mistake for developers in UTC+8 regions. You write the expression thinking in local time, deploy to a server whose cron daemon runs in UTC, and your nightly job fires at 5 pm instead of 9 am — or worse, crosses midnight and fires on a different day entirely. The cron daemon never tells you this is happening.

The UTC-vs-local mismatch: a worked example

Suppose you want a report job at 9:00 every weekday in Asia/Shanghai (UTC+8). You write:

0 9 * * 1-5

On a UTC server, that line fires at 09:00 UTC — which is 17:00 in Shanghai. Your morning report runs in the afternoon. The fix is to subtract 8 hours from the local time to get the UTC equivalent:

0 1 * * 1-5

Now the server fires at 01:00 UTC, which is 09:00 in Shanghai. Simple enough — until midnight is involved.

The weekday-drift trap

When the hour shift crosses midnight, the day of week moves too. Consider a job at 02:00 Shanghai time on Mondays:

0 2 * * 1

Subtracting 8 hours gives 18:00 the previous day in UTC — that is Sunday, not Monday. The correct UTC expression is:

0 18 * * 0

A single converted expression silently inherits the wrong weekday if you only adjust the hour. This is a silent bug: the job still runs weekly, just one day early, and it is trivially easy to miss in review.

Daylight saving time makes it worse

If either timezone observes DST, a statically converted expression is correct for only half the year. When the US East Coast switches between EST (UTC−5) and EDT (UTC−4), a job you converted for winter will be off by one hour in summer — and vice versa. Two silently wrong windows per year, each lasting months.

The robust fix, where your cron implementation supports it, is CRON_TZ:

CRON_TZ=America/New_York
0 9 * * 1-5  # fires at 9 am New York time, year-round

CRON_TZ pins the expression to a named zone so the daemon handles the offset and DST transitions for you. It is supported in Vixie cron, systemd timers (via OnCalendar with a timezone), and most cloud scheduler services. If your implementation does not support it, use the UTC-converted expression and leave a comment documenting the source timezone — your future self will thank you.

Sub-hour offset zones

India Standard Time is UTC+5:30. Nepal is UTC+5:45. If your local timezone has a sub-hour offset, the minute field must absorb the fractional difference. A job at 09:30 IST converts to 04:00 UTC — the 30-minute offset cancels cleanly here, but not always. Zones with 45-minute offsets produce minute values that are rarely what the author intended. Always double-check the minute field when working with these zones.

Translate it correctly

Paste your expression into the cron + timezone translator to see the server-side line, the next runs in both timezones, and a warning for every trap above — weekday drift, DST exposure, and sub-hour alignment issues — before you ship.