MonkeyLoader All articles
Performance

Cron, Clocks and the Missing Hour: Scheduling Loaders for Europe/London

MonkeyLoader
Cron, Clocks and the Missing Hour: Scheduling Loaders for Europe/London

Photo: Groov3, CC BY-SA 4.0, via Wikimedia Commons

The clocks went back in the UK on 25 October 2026. If your scheduled loaders are still running on server local time, that quiet Sunday was probably fine. The next one may not be. Daylight-saving transitions are a twice-yearly reminder that cron expressions are not timezone-aware in the way most of us assume.

The problem is not complicated. It is just easy to ignore until a nightly import runs twice, a report lands in the wrong hour, or a loader skips a run entirely because 01:30 did not exist. In the UK, we move between Greenwich Mean Time and British Summer Time. The switch happens at 01:00 local time. In spring, 01:00 to 01:59 vanishes. In autumn, 01:00 to 01:59 happens twice. Any job scheduled in that window is asking for trouble.

Why 0 1 * * * is a lie twice a year

A cron entry like 0 1 * * * looks precise. It means one o'clock in the morning to the daemon reading it. If that daemon uses the host timezone, and the host timezone is Europe/London, then one o'clock is not always a single moment. On the spring transition, it does not exist. On the autumn transition, it exists twice. Different cron implementations handle this differently: some skip, some run twice, some run once at the first or second occurrence. None of that is a business rule you chose.

The same trap appears in Kubernetes CronJobs, systemd timers, cloud schedulers, and the little Python script that someone attached to a queue. Containers often default to UTC, which is safer, but then a job meant for 07:00 London time drifts by an hour in summer. That is fine for an interval job and not fine for a report that staff expect before the morning stand-up.

Decide what the schedule actually means

Before you write code, decide whether the job is tied to elapsed time or wall-clock time. An interval job, such as refreshing a cache every 15 minutes, should run in UTC. A business job, such as importing a supplier file at 06:00 London time, should be expressed as a local wall time and resolved to UTC for execution. Mixing the two is where the bugs live.

Store schedule definitions with an explicit timezone. Do not store a fixed offset like +01:00. London is not always +01:00. Use Europe/London and let the timezone database do the work. In Python, zoneinfo is enough for most teams:

from datetime import datetime, time
from zoneinfo import ZoneInfo
LONDON = ZoneInfo('Europe/London')
UTC = ZoneInfo('UTC')

Then resolve a local wall time to an instant. The important part is handling the two awkward cases: nonexistent times and ambiguous times.

A small utility that refuses to guess

Here is a compact approach. It checks whether a local time exists, detects ambiguity, and lets the caller choose a fold when the clock goes back. The fold value tells Python which of the two possible offsets to use: 0 for the first occurrence, 1 for the second.

from datetime import datetime
from zoneinfo import ZoneInfo
LONDON = ZoneInfo('Europe/London')
UTC = ZoneInfo('UTC')

def london_offsets(naive):
    first = naive.replace(tzinfo=LONDON, fold=0).utcoffset()
    second = naive.replace(tzinfo=LONDON, fold=1).utcoffset()
    return first, second

def resolve_london(naive, fold=0):
    first, second = london_offsets(naive)
    local = naive.replace(tzinfo=LONDON, fold=fold)
    utc = local.astimezone(UTC)
    roundtrip = utc.astimezone(LONDON).replace(tzinfo=None)
    if roundtrip != naive:
        raise ValueError(f'Nonexistent London time: {naive}')
    if first != second and fold not in (0, 1):
        raise ValueError(f'Ambiguous London time: {naive}')
    return utc

Call resolve_london(datetime(2026, 3, 29, 1, 30)) and it raises, because that time never happens. Call it for 25 October 2026 at 01:30 and it returns the first occurrence unless you pass fold=1. The job then logs both the local intent and the UTC instant. That log line is worth its weight in gold when someone asks why the import ran at the wrong time.

Test the transitions, not just a Tuesday

Write tests for the two Sundays, not for a random day in June. For 2026, the UK transitions were 29 March and 25 October. A useful test set looks like this:

If you use pytest, parametrise the cases and assert on UTC, not on local strings. Local strings are for humans. UTC is for comparisons.

Make the scheduler boring

A few rules keep scheduled loaders predictable. Run cron in UTC where you can, and convert business schedules at the edge. Set TZ=UTC in containers so a stray library does not pick up the host timezone. Avoid scheduling critical jobs between 00:30 and 02:30 on transition days unless you have a specific reason. Make every loader idempotent, with a unique key or a run ledger, so a double execution does not double-charge a customer or duplicate a row. Log the resolved UTC time, the local time, and the timezone name. If a job can be delayed, give it a window rather than a single minute.

The hour you do not lose

The UK clock change is not exotic. It is a known event with a known date, and yet it still catches teams because the bug is invisible for 364 days. Treat timezone handling as part of the loader, not as a deployment detail. Resolve local times explicitly, test both transitions, and make the scheduler say what it means. Then the only thing that changes twice a year is the clock on the wall, not your overnight imports.

All Articles

Related Articles

Peak Season With Two Pairs of Hands: A Loader Checklist for Small UK Retail Teams

Peak Season With Two Pairs of Hands: A Loader Checklist for Small UK Retail Teams

The Two-Second Illusion: Making Council Pages Feel Quick on a Wobbly Connection

The Two-Second Illusion: Making Council Pages Feel Quick on a Wobbly Connection

Your Bundle Is Too Big: Splitting a UK SaaS Front-End by Route Without a Framework Rewrite

Your Bundle Is Too Big: Splitting a UK SaaS Front-End by Route Without a Framework Rewrite