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=UTCin 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 ajava.time.Clockand useClock.fixed(...)in tests. In Python, accept anowcallable or use a library liketime-machineto freeze time. - Run the suite with a non-UTC, DST-observing
TZsuch asAmerica/New_Yorkand again with a far-off zone such asPacific/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;
timestamptzstores an instant, not a zone. - Make the clock and the zone inputs to your code, and test the switch dates.
References
- IANA, Time Zone Database — https://www.iana.org/time-zones
- Python docs,
zoneinfo— https://docs.python.org/3/library/zoneinfo.html - PEP 495, Local Time Disambiguation — https://peps.python.org/pep-0495/
- PostgreSQL docs, Date/Time Types — https://www.postgresql.org/docs/current/datatype-datetime.html
- MySQL Reference Manual, The DATE, DATETIME, and TIMESTAMP Types — https://dev.mysql.com/doc/refman/8.0/en/datetime.html
- Kubernetes docs, CronJob time zones — https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#time-zones
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.