Last Sunday, 9 March 2025, most of the United States and Canada moved their clocks forward. Every year that weekend brings the same tickets: a daily report that ran twice or not at all, a recurring meeting that moved by an hour, a booking for "2:30 am" that cannot exist, a dashboard where Sunday has 23 hours of data and nobody knows why. Europe repeats the exercise on 30 March, and the reverse switch in the autumn brings duplicate hours instead of missing ones.

None of this is exotic. It comes from a small number of decisions that most codebases make implicitly. This guide makes them explicit: what to store, which zone to keep, how gaps and overlaps behave in common languages, which database column types to pick, and how to test time-dependent code without waiting for the next switch. The examples use Python and PostgreSQL, but the rules apply to any stack.

Two different kinds of time

Most bugs come from mixing up two concepts.

An instant is a single point on the global timeline: "the payment was captured at 2025-03-09T07:30:00Z". It is the same moment everywhere. Logs, audit trails, created and updated timestamps, token expiries and event ordering are instants.

A local date-time with a zone is what a human means by "9:00 every weekday in Berlin" or "the concert starts 2025-08-14 at 19:30 in Lisbon". It is a wall-clock reading in a place. The instant it corresponds to depends on that place's rules, and those rules can change between when you store it and when it happens.

The default rule follows from that distinction:

  • Store past events and machine timestamps as UTC instants.
  • Store future human-scheduled events as local date-time plus an IANA zone name, and compute the instant as late as possible.

Use IANA zone names, not offsets

A zone like Europe/Berlin or America/Sao_Paulo refers to an entry in the IANA time zone database (tzdb), which records every offset and DST rule change for that region over time. An offset such as +01:00 only describes one moment. If you store +01:00 for a weekly Berlin meeting, it will be an hour off for the seven months of summer time.

Abbreviations are worse. CST can mean US Central, China Standard or Cuba Standard time; IST can mean India, Ireland or Israel. Never parse or store them as zones.

Governments change rules regularly and sometimes at short notice. Mexico abolished DST for most of the country in 2022, for example, and the tzdb shipped updates to reflect it. That is why future events should keep the zone name, not a precomputed UTC instant: if the rules change, a recomputed instant is right and a stored one is wrong. It also means the tzdb on your servers must be kept current, through OS packages such as tzdata, the language runtime, or a package like Python's tzdata.

DST gaps: times that do not exist

When clocks spring forward, an hour of local time is skipped. On 9 March 2025 in America/New_York, local time went from 01:59:59 straight to 03:00:00, so 02:30 never happened. Libraries handle this differently, and you need to know what yours does:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
gap = datetime(2025, 3, 9, 2, 30, tzinfo=ny)

print(gap)                                         # 2025-03-09 02:30:00-05:00  (not a real time)
print(gap.astimezone(timezone.utc))                # 2025-03-09 07:30:00+00:00
print(gap.astimezone(timezone.utc).astimezone(ny)) # 2025-03-09 03:30:00-04:00

Python does not raise; it attaches the pre-transition offset and the round trip lands on 03:30. Java's ZonedDateTime.of also shifts a gap time forward by the length of the gap. JavaScript's Date with the local zone does the same. That is often acceptable for "remind me at 02:30" but wrong for something like "run the backup between 02:00 and 03:00". Decide on a policy and, for user input, consider rejecting or warning about non-existent times.

DST overlaps: times that happen twice

When clocks fall back, an hour repeats. On 2 November 2025 in New York, 01:30 happens once in EDT and again an hour later in EST. A local time alone is ambiguous:

first = datetime(2025, 11, 2, 1, 30, tzinfo=ny)          # fold=0, earlier
second = first.replace(fold=1)                            # later occurrence

print(first.astimezone(timezone.utc))   # 2025-11-02 05:30:00+00:00
print(second.astimezone(timezone.utc))  # 2025-11-02 06:30:00+00:00

Python exposes the choice through the fold attribute (PEP 495). Java's ZonedDateTime picks the earlier offset by default and offers withLaterOffsetAtOverlap(). If you convert to an instant at the moment the user picks a time, show the offset or the zone abbreviation in the confirmation so the ambiguity is visible.

Arithmetic: "one day later" is not always 24 hours

Adding a day to a local date-time keeps the wall clock; adding 24 hours keeps the elapsed time. Around a DST switch they differ:

from datetime import timedelta

start = datetime(2025, 3, 8, 9, 0, tzinfo=ny)
nxt = start + timedelta(days=1)
print(nxt)                                                         # 2025-03-09 09:00:00-04:00
print(nxt.astimezone(timezone.utc) - start.astimezone(timezone.utc))  # 23:00:00

Python's aware arithmetic is wall-clock arithmetic within one zone, so "tomorrow at 9" stays at 9, and only 23 real hours pass. That is right for a daily alarm and wrong for a 24-hour session expiry. Use UTC instants for durations and local date-times for calendar rules, and be explicit about which one you mean in each place.

Server timezone: pin it to UTC

Code that calls datetime.now() without a zone, new Date().getHours() or date() in PHP depends on the process timezone. That means behaviour changes when a container base image, a CI runner or a developer laptop has a different setting.

  • Set TZ=UTC in container images and on servers.
  • In application code, always ask for aware values: datetime.now(timezone.utc) in Python, Instant.now() in Java, Date.now() for epoch milliseconds in JavaScript.
  • Convert to a user's zone only at the edges: rendering, emails, exports.

Scheduled jobs deserve special care. A cron entry at 30 2 * * * on a host in a DST zone will skip a run in spring and may run twice in autumn, depending on the cron implementation. Run schedulers in UTC, or, if a job really must follow a local business day, use a scheduler that takes an explicit zone, such as the timeZone field of a Kubernetes CronJob, and test the switch dates.

Database column types

The column type decides what the database does with your value.

Database Type What it stores Use for
PostgreSQL timestamptz An instant, normalised to UTC; displayed in the session TimeZone Instants
PostgreSQL timestamp A wall-clock date-time with no zone Local date-time, paired with a zone column
MySQL TIMESTAMP Converted from the session time_zone to UTC; range ends in 2038 Instants, with care
MySQL DATETIME Wall-clock value, no conversion Local date-time, or UTC by convention

Despite its name, PostgreSQL's timestamptz does not store the zone; it stores the instant. For a future local event, a schema like this keeps both what the user meant and a cached instant:

CREATE TABLE meetings (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    local_start  timestamp   NOT NULL,          -- 2025-08-14 19:30
    tz           text        NOT NULL,          -- 'Europe/Lisbon'
    starts_at    timestamptz NOT NULL           -- derived; recompute when tzdata changes
);

UPDATE meetings
SET    starts_at = local_start AT TIME ZONE tz
WHERE  starts_at > now();

timestamp AT TIME ZONE zone interprets the wall-clock value in that zone and returns a timestamptz. Running the update after a tzdata upgrade keeps future events correct.

Testing with fixed clocks and foreign zones

Time-dependent code is only testable if "now" and the zone are inputs:

  • Inject a clock instead of calling now() deep inside business logic. In Java pass a java.time.Clock and use Clock.fixed(...) in tests. In Python, accept a now callable or use a library like time-machine to freeze time.
  • Run the suite with a non-UTC, DST-observing TZ such as America/New_York and again with a far-off zone such as Pacific/Kiritimati (UTC+14). Bugs that assume the server is in UTC show up immediately.
  • Write explicit cases for the switch dates: the gap hour, the overlap hour, the day before and the day after, and month ends.
TZ=America/New_York pytest tests/scheduling
TZ=Pacific/Kiritimati pytest tests/scheduling

Common mistakes

Mistake Why it hurts Do this instead
Storing local server time for timestamps Ambiguous during overlaps, wrong after moving servers Store UTC instants
Storing a UTC instant for a future local meeting Breaks when DST rules change Store local time + IANA zone, derive the instant
Storing offsets like +02:00 as a "timezone" Correct for one moment only Store Europe/Paris and similar names
Using CST, IST and other abbreviations Ambiguous across countries Use IANA names; show abbreviations only for display
Adding 24 hours for "tomorrow" Off by one hour around DST Use calendar arithmetic in the user's zone
Cron jobs in a DST zone Skipped or doubled runs Schedule in UTC or with an explicit zone
Tests that only run in UTC Hide every zone-dependent bug Run tests under several TZ values

Key takeaways

  • Distinguish instants from local date-times; they need different storage.
  • Use UTC for past events and machine timestamps, local time plus an IANA zone for future human events.
  • Know how your language resolves DST gaps and overlaps, and decide a policy for user input.
  • Pin servers and containers to UTC and convert only at the edges.
  • Choose column types deliberately; timestamptz stores an instant, not a zone.
  • Make the clock and the zone inputs to your code, and test the switch dates.

References