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.