DEV Community

Libme
Libme

Posted on

Your Nightly Job Ran Twice on the DST Switch: Making Scheduled Jobs Timezone-Safe

If your scheduler stores a wall-clock time in a local timezone, a job at 01:30 runs twice on the fall-back date and a job at 02:30 runs zero times on the spring-forward date. Setting TZ=America/New_York on your cron does not fix this — it is what causes it. The fix is to separate when the trigger fires from what logical period the job covers, and to make the job claim its logical date before doing work.

I lost a morning to this: a billing summary that emailed customers twice on November 1, once at 01:30 EDT and once at 01:30 EST, 60 minutes apart, from a scheduler nobody had touched in months.

What actually happens at the transition

Two local wall-clock times are broken, not one. In America/New_York, DST for 2026 starts Sunday March 8 and ends Sunday November 1 (EU zones switch on different dates — March 29 and October 25 in 2026, still observed as of mid-2026).

  • Fall back: the clock goes 01:59 → 01:00. Every wall-clock time in the 01:00–01:59 range happens twice, one hour apart in real time. A cron entry for 30 1 * * * fires on both.
  • Spring forward: the clock goes 01:59 → 03:00. Wall-clock times in 02:00–02:59 never occur. A cron entry for 30 2 * * * fires once, twice, or not at all depending on which scheduler you use — none of them agree.

Python's zoneinfo makes the ambiguity visible through PEP 495's fold flag:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
first  = datetime(2026, 11, 1, 1, 30, tzinfo=ny)           # fold=0
second = datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=ny)

print(first.astimezone(timezone.utc))   # 2026-11-01 05:30:00+00:00
print(second.astimezone(timezone.utc))  # 2026-11-01 06:30:00+00:00
print(first == second)                  # True  <- the trap
Enter fullscreen mode Exit fullscreen mode

Those are two different instants an hour apart, and Python reports them as equal, because same-zone comparison ignores fold by design. Any dedupe check written as if run_at == last_run_at: skip silently passes through the duplicate. Convert to UTC before comparing and the check works.

The nonexistent time is quieter. datetime(2026, 3, 8, 2, 30, tzinfo=ny) constructs happily with a -05:00 offset, but round-tripping it through UTC lands on 03:30 EDT — the value you built never existed on any clock.

Takeaway: a local wall-clock time is not a unique instant twice a year, and your language will not raise an error when you treat it like one.

Why "just set the scheduler's timezone" isn't enough

Scheduler-native timezone support is real and worth using, but it only decides when to fire. It gives you nothing about the far more expensive question: did this job already run for this business day?

Platform Local timezone support Behavior you still have to handle
Linux cron (CRON_TZ=) Yes Ambiguous hour can fire twice; gap behavior varies by cron implementation
systemd timers Yes (OnCalendar + Timezone=) Persistent=true fires missed runs at boot — a second execution
Kubernetes CronJob Yes, spec.timeZone (stable since 1.27) Docs state a schedule may create two Jobs or none for one slot
GitHub Actions schedule No — UTC only Delivery is best-effort and can be delayed under load
Amazon EventBridge Scheduler Yes, IANA names Retries and flexible time windows can re-invoke the target
Google Cloud Scheduler Yes, timeZone At-least-once delivery; the target must be idempotent

Notice the pattern in the right column: every one of these is at-least-once. DST is not a special case, it is just the twice-a-year reminder that your job needs to survive being invoked more than once for the same period.

Takeaway: scheduler timezone settings fix the wall clock, not the duplicate; none of these platforms promise exactly-once.

The two-clock rule

Every scheduled job has two clocks, and mixing them is the actual bug:

  1. The trigger clock — a real instant, always UTC, best-effort, may fire early, late, or twice.
  2. The logical clock — the business period the run covers ("the report for 2026-11-01"), which advances exactly once per period no matter what the trigger does.

Once you name the logical date, the fix writes itself. Tick frequently in UTC, compute whether the local target time has passed for the current logical date, then claim that date before working:

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

UTC = timezone.utc

def local_target_utc(day: date, wall: time, tz: ZoneInfo) -> datetime:
    """UTC instant for `wall` clock time on `day` in `tz`.
    Nonexistent (spring forward) -> the instant the clock jumps to.
    Ambiguous (fall back) -> the first, earlier occurrence."""
    first = datetime.combine(day, wall).replace(tzinfo=tz, fold=0)
    resolved = first.astimezone(UTC).astimezone(tz)
    if resolved.time() != wall:          # the wall time was skipped
        return resolved.astimezone(UTC)
    return first.astimezone(UTC)

def is_due(now_utc: datetime, tz: ZoneInfo, wall: time) -> date | None:
    logical = now_utc.astimezone(tz).date()
    return logical if now_utc >= local_target_utc(logical, wall, tz) else None
Enter fullscreen mode Exit fullscreen mode

Verified against the 2026 transitions in America/New_York: a 02:30 target on March 8 resolves to 07:30 UTC (03:30 EDT, the moment the clock lands), and a 01:30 target on November 1 resolves to 05:30 UTC — the first 01:30, deterministically, not whichever one the scheduler happens to hit.

The claim is a unique constraint, not a lock you have to think about:

create table job_runs (
  job_name     text        not null,
  logical_date date        not null,
  started_at   timestamptz not null default now(),
  primary key (job_name, logical_date)
);
Enter fullscreen mode Exit fullscreen mode
cur.execute(
    "insert into job_runs (job_name, logical_date) values (%s, %s) "
    "on conflict do nothing",
    ("billing_summary", logical),
)
if cur.rowcount == 0:
    return  # already ran for this logical date
Enter fullscreen mode Exit fullscreen mode

Run this every 15 minutes in UTC from any scheduler on the table above and the DST question disappears: extra triggers hit the conflict and return, a missed trigger is picked up by the next tick, and a retried invocation is free. One caveat worth handling on first deploy — a brand-new job whose target time has already passed today will fire immediately, so seed the table with today's row if you don't want that.

Takeaway: a unique key on (job, logical_date) turns at-least-once delivery into exactly-once work, and DST stops being a scheduling problem.

Which approach for which job?

Approach Survives gap/ambiguity Survives duplicate trigger Complexity
Local-timezone cron entry No No Lowest
UTC cron + convert inside the job Yes No Low
Frequent UTC tick + claim logical date Yes Yes Medium
Durable execution engine Yes Yes Highest

For jobs where a duplicate run is merely wasteful (cache warmers, syncs that overwrite), UTC cron plus in-job conversion is enough. For anything that sends, charges, or emits an external side effect, use the claim. If you want the managed version of the whole model, Temporal gives you schedules with a timezone plus workflow-ID deduplication so a repeated trigger resolves to the same durable execution rather than a second one — at the cost of running a server and rewriting the job as a workflow. If your jobs are already a DAG of data transformations, Airflow's data-interval model bakes the logical clock in as a first-class concept, though it brings a scheduler, metadata database, and upgrade treadmill with it.

How do you test this before November?

Don't wait for the transition. Enumerate it — step a fake clock through the window in UTC and assert the run count:

def test_one_run_per_logical_date():
    ny, claimed = ZoneInfo("America/New_York"), set()
    t = datetime(2026, 10, 31, 12, 0, tzinfo=UTC)
    while t < datetime(2026, 11, 2, 12, 0, tzinfo=UTC):
        logical = is_due(t, ny, time(1, 30))
        if logical:
            claimed.add(logical)   # set stands in for the unique constraint
        t += timedelta(minutes=15)
    assert len(claimed) == 3       # Oct 31, Nov 1, Nov 2 — one each
Enter fullscreen mode Exit fullscreen mode

Point the same test at March 8 and at a European zone. The scheduling logic must be a pure function of (now_utc, tz, wall_time) for this to be testable at all — which is the real reason to keep it out of the cron expression.

Takeaway: if you cannot simulate the DST window in a unit test, your scheduling logic lives in the wrong layer.

FAQ

Does cron run a job twice during daylight saving time?
Yes, if the cron entry uses a local timezone and the scheduled time falls in the repeated hour — typically 01:00–01:59 on the fall-back date. Times in the skipped hour on the spring-forward date may not run at all. A cron entry in UTC fires exactly once but drifts by an hour in local terms.

Should I store timestamps in UTC or local time?
Store instants in UTC (timestamptz in Postgres) and store the user's IANA timezone name, like Europe/Berlin, in a separate column. Never store a fixed offset such as +01:00 — it is wrong for half the year and cannot be corrected later.

How do I run a job at 9am in each user's local timezone?
Tick every 15 minutes in UTC, resolve 09:00 in each user's IANA zone to a UTC instant for that user's current local date, and process users whose target has passed and who have no row yet for that logical date. Do not precompute a year of trigger timestamps — timezone rules change by government decree, and the tzdata update will invalidate them.

Bottom line

If a duplicate run is harmless, put the schedule in UTC and convert to local time inside the job — two lines of zoneinfo and you are done. If a duplicate run sends an email, charges a card, or posts to a partner API, add the logical-date claim; it costs one table and one insert, and it retires the DST question along with retries, missed triggers, and overlapping runs. Keep the scheduler dumb and the job smart: any platform can fire a UTC tick, but only your code knows what period the work belongs to. Test the March and November windows now, while it is cheap.

Related reading

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The 'claim the logical date before doing work' part is the real fix — most write-ups stop at 'store UTC' which only solves the double-fire, not the skip. We hit the mirror image of this with a maintenance cron that had an idempotency key built from local time: on the spring-forward night one hour simply vanished from the run log and nobody noticed for two weeks because the job looked healthy.

Did you land on a strategy for the jobs that must run in wall-clock terms (say, a report at 09:00 local for humans)? We ended up computing the next fire time explicitly per-zone instead of trusting cron to interpret the DST edge, but I'm curious if you found something less hand-rolled.