Cron Jobs and Time Zones: Scheduling Pitfalls

The Core Problem

Cron uses wall-clock time, not UTC. If your server is set to a local timezone and DST occurs, a cron job scheduled for 0 2 * * * (2:00 AM daily) might:

  • Run twice when clocks fall back (2:00 AM occurs twice)
  • Never run when clocks spring forward (2:00 AM is skipped)
  • Drift by an hour throughout summer vs winter if you expected UTC behavior

The Safe Solution: Set Servers to UTC

The simplest fix: set your server's system timezone to UTC. Then cron always uses UTC, and DST is irrelevant. This is the standard practice for production servers:

# Check current timezone
timedatectl   # Linux (systemd)
date          # Any Unix

# Set to UTC
sudo timedatectl set-timezone UTC     # systemd Linux
sudo ln -sf /usr/share/zoneinfo/UTC /etc/localtime  # legacy

Cron with Explicit Timezone (GNU cron / Vixie cron)

Some cron implementations support the CRON_TZ or TZ environment variable to set the timezone per crontab:

CRON_TZ=America/New_York
# Run at 9 AM New York time every weekday
0 9 * * 1-5 /usr/local/bin/send-morning-report.sh

CRON_TZ=Asia/Seoul
# Run at midnight Seoul time
0 0 * * * /usr/local/bin/daily-backup.sh

Warning: Not all cron implementations support this. Test on your specific OS.

Modern Schedulers: Better Timezone Support

Modern job schedulers provide explicit timezone support:

# Celery (Python)
from celery.schedules import crontab
app.conf.beat_schedule = {
    "morning-report": {
        "task": "tasks.morning_report",
        "schedule": crontab(hour=9, minute=0),
        # Always use UTC in Celery — convert at task level
    }
}

# GitHub Actions — always UTC
on:
  schedule:
    - cron: "0 9 * * 1-5"  # 9 AM UTC = 6 PM KST

# AWS EventBridge (CloudWatch Events) — UTC only
# "cron(0 9 ? * MON-FRI *)"  # 9 AM UTC

Django-Q / django-tasks Scheduling

# In Django, schedule tasks in UTC
from django.utils import timezone
from datetime import timedelta

# Schedule for a specific local time
import zoneinfo
seoul = zoneinfo.ZoneInfo("Asia/Seoul")
target_local = datetime(2024, 3, 1, 9, 0, tzinfo=seoul)
target_utc = target_local.astimezone(timezone.utc)
Task.objects.create(run_at=target_utc)

DST-Safe "Business Hours" Scheduling

If you need to send a report at "9 AM business time" for users in New York, do not hardcode UTC-5. Store the business rule as a timezone name and compute the UTC equivalent fresh each day:

from zoneinfo import ZoneInfo
from datetime import datetime, time, timezone

def next_9am_utc(iana_tz: str) -> datetime:
    tz = ZoneInfo(iana_tz)
    today = datetime.now(tz).date()
    local_9am = datetime.combine(today, time(9, 0), tzinfo=tz)
    return local_9am.astimezone(timezone.utc)

Related Terms