
You write 0 21 * * * to run a report at 9 PM. It runs, just not when you expected: at 2:30 AM India time. Nothing is broken. Cron did exactly what you asked, in a timezone you didn't know it was using.
This post explains why that happens, the two mistakes that catch a lot of people in India, and four ways to fix it, including a small tested Python helper.
The cause: cron uses the server's timezone
Cron has no idea where you live. It reads the clock of the machine it runs on, and most cloud servers and VPS images default to UTC. Check yours:
timedatectlLook for the Time zone line. If it says Etc/UTC or UTC, every cron expression on that machine is in UTC.
IST is UTC+5:30 and has no daylight saving. So 0 21 * * * means 21:00 UTC, which is 02:30 IST the next day.
Mistake 1: forgetting the half hour
Most timezones differ from UTC by whole hours, so it's easy to subtract 5 instead of 5:30. 9 PM IST is 15:30 UTC, so the correct line is 30 15 * * *. Write 0 15 * * * and the job fires at 8:30 PM IST.
The conversion is always UTC = IST − 5:30:
IST | UTC | UTC day |
|---|---|---|
00:00 | 18:30 | previous day |
01:00 | 19:30 | previous day |
05:29 | 23:59 | previous day |
05:30 | 00:00 | same day |
09:00 | 03:30 | same day |
21:00 | 15:30 | same day |
23:30 | 18:00 | same day |
Mistake 2: the midnight day shift
Anything between 00:00 and 05:29 IST lands on the previous day in UTC, so the day fields change too.
Take "every Monday at 1 AM IST". The server sees that moment as Sunday 19:30 UTC, so the line is 30 19 * * 0. If you keep the 1 in the weekday field (30 19 * * 1), the job fires on Monday 19:30 UTC, which is Tuesday 1 AM IST, a full day late.
Day of the month is worse. "The 1st at 2 AM IST" is 20:30 UTC on the last day of the previous month, and that day is 28, 29, 30 or 31. Plain cron can't express it, so use one of the timezone-aware options below.
Choosing a fix
flowchart TD
A["You want a job at a fixed IST time"] --> B{"Daily or weekly, in a zone without daylight saving?"}
B -- "Yes" --> C{"Do you want to keep plain cron?"}
B -- "No" --> E["Use a timezone-aware scheduler"]
C -- "Yes" --> D["Option 1: convert to UTC"]
C -- "No" --> E
E --> F["Option 2: systemd timer"]
E --> G["Option 3: scheduler inside your app"]Option 1: convert to UTC (works everywhere)
Keep the server on UTC and do the arithmetic once, with code instead of by hand. This helper uses only the standard library (zoneinfo needs Python 3.9 or newer):
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] # cron numbering: 0 = Sunday
def _zone(tz):
zone = ZoneInfo(tz)
jan = datetime(2026, 1, 4, 12, tzinfo=zone).utcoffset()
jul = datetime(2026, 7, 4, 12, tzinfo=zone).utcoffset()
if jan != jul:
raise ValueError(f"{tz} uses daylight saving; a fixed UTC cron line would drift. "
"Use a timezone-aware scheduler instead.")
return zone
def _day(dow):
# Any real week works for a zone without DST. 4 Jan 2026 is a Sunday.
return datetime(2026, 1, 4) + timedelta(days=0 if dow in (None, "*") else int(dow))
def to_utc_cron(hour, minute, dow=None, tz="Asia/Kolkata"):
"""Local wall-clock time -> UTC cron expression (daily, or weekly if dow is given)."""
local = _day(dow).replace(hour=hour, minute=minute, tzinfo=_zone(tz))
utc = local.astimezone(UTC)
utc_dow = "*" if dow is None else (utc.weekday() + 1) % 7 # Python Mon=0 -> cron Mon=1
return f"{utc.minute} {utc.hour} * * {utc_dow}"
def explain(cron, tz="Asia/Kolkata"):
"""UTC cron expression -> what it means in the local zone."""
fields = cron.split()
if len(fields) != 5:
raise ValueError("expected 5 cron fields")
minute, hour, dom, month, dow = fields
if not (minute.isdigit() and hour.isdigit() and dom == month == "*"
and (dow == "*" or (dow.isdigit() and int(dow) <= 7))):
raise ValueError("only simple daily or weekly lines are supported "
"(one minute, one hour, optional single weekday)")
utc = _day(dow).replace(hour=int(hour), minute=int(minute), tzinfo=UTC)
local = utc.astimezone(_zone(tz))
when = "daily" if dow == "*" else DAYS[(local.weekday() + 1) % 7]
return f"{cron:<14} = {when} {local:%H:%M} {local.tzname()}"Try it:
>>> to_utc_cron(21, 0) # every day at 9 PM IST
'30 15 * * *'
>>> to_utc_cron(1, 0, dow=1) # every Monday at 1 AM IST
'30 19 * * 0'
>>> explain("0 21 * * *") # the line from the intro
'0 21 * * * = daily 02:30 IST'explain is the useful half: paste a daily or weekly line and see what it means in IST. It handles one minute, one hour and an optional single weekday, and raises an error for anything fancier (ranges, steps, day of month) instead of guessing. Run it on your existing jobs before you trust them.
This only works for zones without daylight saving, like IST, and the helper refuses zones that have it. For a zone that changes its clocks, a fixed UTC expression is off by an hour for part of the year, so use Option 2 or 3 there.
Option 2: a systemd timer with an explicit timezone
systemd timers accept a timezone name inside the schedule, so you can write the time you mean:
# /etc/systemd/system/report.timer
[Unit]
Description=Daily report at 9 PM IST
[Timer]
OnCalendar=*-*-* 21:00:00 Asia/Kolkata
Persistent=true
[Install]
WantedBy=timers.target# /etc/systemd/system/report.service
[Unit]
Description=Daily report
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/report/run.pyEnable it and check the schedule:
sudo systemctl daemon-reload
sudo systemctl enable --now report.timer
systemd-analyze calendar "*-*-* 21:00:00 Asia/Kolkata"
systemctl list-timers report.timersystemd-analyze calendar prints the next run in your timezone and in UTC, so you can confirm the conversion without waiting. Persistent=true also runs a missed job after the server was down. Run systemd-analyze calendar first: on a very old systemd it will reject the timezone name, which is better to learn now than at 9 PM.
The monthly case from earlier is easy here. "The 1st at 2 AM IST" is just OnCalendar=*-*-01 02:00:00 Asia/Kolkata, and systemd's ~ syntax covers the last day of the month (*-*~1), which cron can't express.
A note on CRON_TZ: you will see advice to add CRON_TZ=Asia/Kolkata to your crontab. That works in cronie, the cron used on RHEL, Fedora, AlmaLinux and Rocky. Debian and Ubuntu's cron runs jobs in a single timezone, so the line can be silently ignored. Check with man 5 crontab on your machine, and test it before you rely on it.
Option 3: let the app schedule itself
If the job lives inside a long-running app, give its scheduler the zone. Most scheduling libraries take a timezone argument; pass Asia/Kolkata there and skip the conversion. Check your library's documentation for the exact name and version-specific syntax.
Option 4: change the server's timezone (usually don't)
sudo timedatectl set-timezone Asia/Kolkata makes cron expressions mean IST, but it also changes every log timestamp and every other job on the machine, and anyone who later reads your logs will assume UTC. If you do it, restart cron afterwards (sudo systemctl restart cron, or crond on the RHEL family). I'd keep servers on UTC.
Verify, don't assume
Run these on the server:
timedatectl # which timezone is this server in?
date; date -u; TZ=Asia/Kolkata date # the same instant, three ways
journalctl -u cron --since today # when did cron actually start it? (Debian/Ubuntu)On older Debian and Ubuntu setups the same lines are in /var/log/syslog, and on RHEL-family systems the cron log is /var/log/cron (the service is crond there). Better still, make every job log its own start time with the zone, so a wrong schedule is obvious. Put this line in the script itself:
echo "started $(date -u +%FT%TZ) / $(TZ=Asia/Kolkata date +%FT%T%z)" >> /var/log/report.logDon't paste it straight into a crontab line: % has a special meaning there and must be written as \%.
Two more traps
cron.daily isn't at midnight. On a stock Debian or Ubuntu server, scripts in /etc/cron.daily typically start somewhere between about 06:25 and 09:00 server time, depending on whether anacron is installed. On a UTC server that's late morning or early afternoon in India, not midnight. If you need a specific time, write your own cron line or timer.
Naive datetimes in Python. datetime.now() returns the server's local time with no timezone attached, so the same code gives different answers on your laptop and on the server:
from datetime import datetime
from zoneinfo import ZoneInfo
datetime.now() # naive: server's timezone, UTC on most VPS
datetime.now(ZoneInfo("Asia/Kolkata")) # explicit: correct anywhereOn Windows, zoneinfo needs the timezone database from pip install tzdata. Store timestamps in UTC and convert to IST only when you show them to a person.
Cheat sheet
Cron uses the server's timezone, and most servers are UTC.
IST = UTC + 5:30. Subtract 5:30 to get UTC, not 5.
Anything from 00:00 to 05:29 IST moves to the previous UTC day, including the weekday and the date.
For daily and weekly jobs, convert to UTC with code. For monthly jobs or zones with daylight saving, use a systemd timer or an app scheduler with an explicit timezone.
Don't rely on
CRON_TZon Debian or Ubuntu.Log start times in UTC and IST, and check the first run instead of assuming.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!