CodeCrafting All articles
Build & Delivery

The Hour That Runs Twice: Testing Your Stack Against GMT and BST

CodeCrafting
The Hour That Runs Twice: Testing Your Stack Against GMT and BST

Every year, on the last Sunday of October, the UK gets an extra hour. Most of us spend it asleep. A certain class of production bug spends it running twice.

Then, on the last Sunday in March, the clocks jump from 00:59:59 straight to 02:00:00 and a window of local time simply never exists. Anything scheduled inside that window is not delayed — it is skipped, quietly, and nobody notices until a report is missing on Monday morning.

We talk about 'supporting time zones' as though it were a feature you add for international customers. In the UK you ship for two zones whether you like it or not, and the overwhelming majority of test suites only ever exercise one of them.

The hour that happens twice

At 02:00 BST on the last Sunday of October, clocks fall back to 01:00 GMT. Local time 01:30 therefore occurs twice: once at 00:30 UTC and again at 01:30 UTC. Two distinct instants wearing an identical wall-clock reading.

The classic failure is the cron job pinned to 01:30. On a host with TZ=Europe/London, it fires twice — invoices generated twice, reminder emails sent twice, a nightly sync executed against a system that very much wasn't expecting a second visit.

Spring is the mirror image. At 01:00 GMT the clock jumps to 02:00 BST, so 01:30 local never occurs. The job doesn't run twice; it doesn't run at all. That's the more dangerous of the two, because duplicate work tends to be visible and missing work tends not to be.

Where the bug actually lives

Nine times out of ten it isn't in your application code. It's in the gap between three things that each believe they know what time it is: the container (usually defaulting to UTC), the database (which may or may not carry a zone), and the scheduler (a host cron, a Kubernetes CronJob, a cloud scheduler, or a queue library with opinions of its own). Each is individually correct and collectively wrong.

Store instants in UTC, name zones explicitly

Rule one: timestamptz in Postgres, not timestamp. A column without a zone is a column storing an opinion.

Rule two: never trust the runtime's default zone. Set it, assert it, and be suspicious when a test only passes on your laptop.

Rule three: convert by naming a region, never by adding an offset. Europe/London carries the whole history of British summer time, including the years when we experimented with double summer time and the odd business in 1968 when the clocks simply didn't go back. An offset of +01:00 carries none of that.

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

LONDON = ZoneInfo("Europe/London")
UTC = ZoneInfo("UTC")

def next_local_run(now_utc: datetime, hour: int, minute: int) -> datetime:
    """Next occurrence of a London wall-clock time, returned as a UTC instant."""
    local = now_utc.astimezone(LONDON)
    candidate = local.replace(hour=hour, minute=minute, second=0, microsecond=0)
    if candidate <= local:
        candidate += timedelta(days=1)
    return candidate.astimezone(UTC)

Note the trap hiding in that replace(). On the autumn date, 01:30 is ambiguous, and zoneinfo picks fold=0 — the first one. If you wanted the second, you get a job that runs an hour early. Either set fold=1 deliberately or, better, don't schedule on local wall clock at all.

Schedule in UTC, display in local

For most batch work the honest fix is to schedule in UTC and only translate for humans. A nightly reconciliation at 02:00 UTC is 02:00 UTC in January and 03:00 BST in July, and nobody minds, because the job's contract is 'roughly nightly', not 'when the clock on the wall says two'.

The jobs that genuinely need local time are the ones tied to human life: a 09:00 digest, a 17:00 cut-off, a midnight billing boundary. Those need a named zone and a written policy for the missing and duplicated hours. Pick one and put it in the runbook:

Expiry is a calendar problem

'Expires in 24 hours' and 'expires tomorrow' are different products. If a voucher is valid until end of day, the day is a London day, and that day is 23, 24 or 25 hours long depending on the date. Storing a UTC timestamp and rendering it back with a fixed offset will be wrong for roughly seven months of the year.

Date-only fields deserve their own type. A delivery date, a billing period, a reporting day — none of those are instants, and none should be dragged through a time zone conversion. Keep them as date and format them without a zone at all.

Make the logs line up

The other half of the problem is diagnosis. Application logs in UTC, a load balancer reporting in local time, a support ticket saying 'it happened around half one' and an incident timeline assembled by three people in two zones. After the clocks move, the gap between what the logs say and what the humans remember shifts by an hour.

Log UTC, always, with an explicit Z or offset. Treat local time as a rendering concern. If your dashboards let an engineer choose Europe/London, convert at the edge and print the offset on the axis — a chart that steps from +01:00 to +00:00 mid-way is doing you a favour, not confusing you.

What to actually put in CI

This is the part most teams skip, and it's cheap:

  1. Run the whole suite twice, once with TZ=UTC and once with TZ=Europe/London. A surprising number of bugs are just tests written in the author's zone.
  2. Freeze the clock either side of each transition instant — 00:59:59 and 01:00:01 UTC on both changeover dates.
  3. Property test the round trip: for a thousand random instants across the year, parse(format(t)) == t.
  4. Assert on the ambiguous case explicitly. Round-trip 01:30 on the autumn date and check which fold comes back.
  5. Test the scheduler boundary. Given a job at 01:30 local, how many times does it fire on 25 October? If the answer is 'depends on the host's tzdata', that's a finding.

A parameterised test does most of the work:

@pytest.mark.parametrize("utc_instant", [
    "2026-03-29T00:59:59Z",  # last instant of GMT
    "2026-03-29T01:00:00Z",  # 02:00 BST — 01:30 never happened
    "2026-10-25T00:30:00Z",  # first 01:30 local
    "2026-10-25T01:30:00Z",  # second 01:30 local
])
def test_billing_window_boundaries(utc_instant):
    ...

Two zones, one habit

None of this is exotic. Europe/London is in every tz database, the transitions are published years ahead, and the whole thing takes an afternoon to test properly. It bites because it happens at 01:00 on a Sunday, four months after anyone last thought about it, and because the bug is invisible in a demo.

Treat the clock change as a release. Put the transitions in the runbook, put them in the suite, and the extra hour in October can go back to being sleep.

All Articles

Related Articles

From Background Hiss to Clear Signal: A Warning Budget for Small Teams

From Background Hiss to Clear Signal: A Warning Budget for Small Teams

Guardrails, Not Gatekeepers: Putting Accessibility Checks in the Build

Guardrails, Not Gatekeepers: Putting Accessibility Checks in the Build

Error Messages That Help: Writing Copy People Can Act On

Error Messages That Help: Writing Copy People Can Act On