Skip to content

Dates and Times — Timezones, DST, Leap Years, Epoch

We have walked the number line, stress-tested strings, and enumerated collections. Time looks like it belongs to the first of those — a date is just a number, a duration is just subtraction. That is the trap. Time is a number wearing a calendar’s clothing, and the calendar is a political, astronomical, thousand-year-old kludge that no arithmetic respects.

This is the page where “it worked yesterday” stops being a figure of speech. Code that passed every test on Tuesday can fail on Wednesday because Wednesday was the day the clocks changed, or the day the year turned, or the day a leap second was inserted, or — for the truly unlucky — the day a 32-bit clock ran out of numbers. The bugs here are not in your logic; they are in your assumptions about how time behaves. Your job is to find each assumption and break it on purpose.

The single most important defensive decision in time-handling is where you keep the ambiguity. A timestamp with no timezone is not a point in time — it is a guess. 2026-03-08 02:30 is meaningless until you know which 02:30: in Tokyo, in London, in New York, these are three different instants hours apart, and one of them may not exist at all.

The defensive default is a discipline, not a library call:

STORE everything in UTC — one unambiguous timeline, no offsets, no DST
COMPUTE in UTC — durations, comparisons, scheduling, sorting
CONVERT to a local timezone ONLY at the display edge, as late as possible

UTC is a single, monotonic-enough, DST-free timeline. It never springs forward, never falls back, has no offset. Every instant maps to exactly one UTC value, so comparisons and subtractions are honest. The moment you store a local time without its zone, you have thrown away information you can never fully recover — you cannot reconstruct the instant, you cannot safely compare two of them, and you certainly cannot subtract them.

NAIVE (local, no zone) AWARE (UTC + zone at edge)
"2026-03-08 02:30" instant: 2026-03-08T07:30:00Z
which zone? which offset? displayed as: 2026-03-08 02:30 EST
is it during a DST gap? one instant, rendered on demand
→ information already lost → information preserved

A naive datetime (no zone attached) is acceptable only as a transient display value. The instant it becomes something you store, compare, or do math on, it must be aware — anchored to UTC. This is why the test that matters is not “does my function format a date,” but “does an instant survive a round trip through storage and back to the same instant, regardless of the server’s local timezone?”

DST: the hour that vanishes and the hour that repeats

Section titled “DST: the hour that vanishes and the hour that repeats”

Daylight Saving Time is where the local clock stops being a number line and becomes a number line with a tear in it. Twice a year, in most zones that observe it, the local time skips or repeats — and every duration and schedule that assumed “local time increases by one second per second” breaks.

SPRING FORWARD (US, typically March, ~02:00 local)
01:58 01:59 03:00 03:01 ...
02:00–02:59 NEVER HAPPENED — this local hour does not exist
FALL BACK (US, typically November, ~02:00 local)
01:58 01:59 01:00 01:01 ... 01:59 02:00 ...
01:00–01:59 HAPPENS TWICE — ambiguous: which 01:30 do you mean?

Two distinct classes of bug fall out of this tear:

  • The gap (spring forward). A local time like 02:30 simply does not exist on that day. A user who sets an alarm, a cron job scheduled for 02:30, a “run daily at 2:30 AM” batch — all of these ask for an instant that was skipped. Does your code error, silently skip, run at 03:30, or run twice on the days around it? Each is a decision, and untested code makes it by accident.
  • The overlap (fall back). A local time like 01:30 happens twice, one hour apart. “The event logged at 01:30” is now ambiguous. Order two such events and you may reverse them. Schedule “every hour” across the boundary and you fire twice or skip an hour.

And the reason durations break: computing “24 hours from now” in local time across a DST boundary gives the wrong instant, because one of those days was 23 or 25 hours long.

"Add one day" on a spring-forward date:
local: 2026-03-07 09:00 + 1 calendar day → 2026-03-08 09:00 (23h later!)
UTC: same instant + 24h → a DIFFERENT local time
"One day" is a CALENDAR operation (add 1 to the date field).
"24 hours" is a DURATION operation (add 86,400 seconds).
On DST days these give different answers — and mixing them is the bug.

The discipline: do duration math (“in 24 hours”) on UTC instants, and do calendar math (“tomorrow at 9am local”) in the target zone with a real timezone library that knows the offset changed. Never subtract two local times to get a duration.

Under the hood — the tz database and why offsets aren’t fixed

Section titled “Under the hood — the tz database and why offsets aren’t fixed”

The rules for “what is the offset in Berlin on this date” are not a formula — they are a table, the IANA time zone database (tzdata), updated several times a year as governments change their DST rules. Two consequences for testing:

  • A timezone is a name (America/New_York), not an offset (-05:00). The offset for that name changes twice a year, and the historical offsets change as governments legislate. Storing -05:00 where you meant “New York time” is the same information-loss bug as storing a naive local time.
  • Your tests depend on the tzdata version installed on the machine. A test asserting a specific past or future offset can go red purely because someone updated the OS package, or because a country abolished DST. Pin the tz rules you rely on, and prefer asserting behaviour (round-trips, ordering) over specific offset numbers where you can.

Leap years, the century rule, and leap seconds

Section titled “Leap years, the century rule, and leap seconds”

“A year is 365 days” is false one year in four. The full rule is older and trickier than most code assumes:

LEAP YEAR RULE (Gregorian):
divisible by 4 → leap (2024, 2028)
BUT divisible by 100 → NOT leap (1900, 2100) ← the trap
BUT divisible by 400 → leap (2000, 2400) ← the trap's trap
2000 → leap (÷400) 2100 → NOT leap (÷100, not ÷400)
2024 → leap (÷4) 1900 → NOT leap (÷100, not ÷400)

The infamous edge is February 29. It exists only in leap years, so any code that constructs, validates, or increments a date must handle it: rejecting 2023-02-29 as invalid, accepting 2024-02-29, and correctly answering “one year after 2024-02-29” (there is no 2025-02-29, so what is the answer — Feb 28 or Mar 1?). That is a decision your tests must pin.

The century rule is a real trap because the simple year % 4 == 0 check is correct for every year from 1901 to 2099 — the entire span most developers ever test in. It fails only at century boundaries, which is exactly why the naive version ships and then detonates decades later.

Then there is the deeper lie: a minute is not always 60 seconds. To keep clocks aligned with the Earth’s slightly irregular rotation, timekeepers occasionally insert a leap second, producing a minute with 61 seconds (23:59:60). Twenty-seven leap seconds were added between 1972 and the early 2020s. Code that hard-codes “86,400 seconds per day” or rejects a :60 seconds field is wrong on those days. Many systems paper over this by “smearing” the extra second across hours, but the point stands: if your correctness depends on a minute being exactly 60 seconds, you have a latent bug.

Under the calendar, most systems count time as a single integer: seconds since the Unix epoch, 1970-01-01T00:00:00Z. This is the clean, unambiguous representation — one number, no zones, no DST. But integers have the same walls we met on the numbers page, and time drives straight into them.

Unix epoch = 1970-01-01T00:00:00Z
timestamp 0 = the epoch itself (test it!)
timestamp -1 = 1969-12-31T23:59:59Z (BEFORE 1970 — negative, valid!)
timestamp 1_700_000_000 ≈ Nov 2023
Negative timestamps are real dates, not errors. Code that does
`if ts < 0 { reject }` silently corrupts every birthday before 1970.

Three edges hide in integer time, and each has burned real systems:

  • Negative timestamps (before 1970). A birthdate of 1965, a historical record, a photo from the 60s — all have negative Unix timestamps. Code that treats negative as invalid, or an unsigned type that cannot represent them, silently mangles the past.
  • Seconds vs milliseconds vs nanoseconds. Unix time is seconds; JavaScript’s Date.now() is milliseconds; some APIs use microseconds or nanoseconds. Mixing them is off by a factor of 1000 (or a billion) — a timestamp that should read “next Tuesday” reads “the year 56,000,” or “1970.” This is one of the most common time bugs in existence, and it passes every test that only uses “now” because “now” looks plausible in either unit.
  • The Year 2038 problem. A signed 32-bit integer counting seconds overflows at 2,147,483,647 seconds after the epoch — 03:14:07 UTC on 19 January 2038. One second later it wraps to -2,147,483,648, which is 1901. This is the numbers page’s silent overflow, wearing a clock.
signed 32-bit seconds since epoch:
2038-01-19 03:14:07 UTC = 2,147,483,647 (i32::MAX)
+1
1901-12-13 20:45:52 UTC = -2,147,483,648 (wrapped to i32::MIN)

The lesson mirrors Ariane 5 from the numbers page: a fixed-width integer that was safely bounded in its original era becomes an extreme as the system’s reach extends past it. Test at the wall — i32::MAX seconds, timestamp 0, timestamp -1 — not just at “now.”

Every rule above shares one testing problem: the real clock is an uncontrollable, unrepeatable, hidden input. A test that calls now() reads a different value every run, cannot reach the interesting instants (the DST boundary, Feb 29, the 2038 wall), and is silently coupled to the machine’s timezone. Such a test is green in your region and may be red in another — or green today and red at 2 AM on a Sunday in March.

The fix is to stop treating time as ambient and start treating it as an injected dependency. Do not let code reach out to the global clock; hand it a clock.

# HARD TO TEST — time is a hidden global input
def is_expired(token):
return token.expires_at < datetime.now(timezone.utc) # which "now"?
# TESTABLE — the clock is a parameter you control
def is_expired(token, now):
return token.expires_at < now
def test_expiry_boundary():
expiry = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
token = Token(expires_at=expiry)
# Freeze the clock at the exact edges and assert each side:
assert not is_expired(token, now=expiry - timedelta(seconds=1)) # just before
assert is_expired(token, now=expiry + timedelta(seconds=1)) # just after
assert is_expired(token, now=expiry) # AT the boundary

A frozen, injected clock turns time back into a boundary-value problem you already know how to attack: you can stand at the spring-forward gap, the fall-back overlap, Feb 29, timestamp 0, and the 2038 second, and assert what happens at each. Libraries exist for exactly this (freezegun in Python, @sinonjs/fake-timers in JS, an injectable Clock in Java/Go), but the principle is deeper than any library: time is a dependency, and dependencies get injected.

The second half of the discipline is to pin the timezone in the test itself, so the suite does not depend on the machine’s locale.

# A suite that is green in one region and red in another is not a suite —
# it is a coin flip that happens to land your way at your desk.
# Pin the zone explicitly rather than trusting the host:
def test_schedules_across_dst_gap():
tz = ZoneInfo("America/New_York")
# 2026-03-08 02:30 does not exist in this zone — assert the chosen behaviour:
with pytest.raises(NonExistentTimeError):
make_aware(datetime(2026, 3, 8, 2, 30), tz)

If a test’s outcome changes when you move the laptop across a timezone or run CI in a different region, the test is under-specified. Pin the zone, freeze the clock, and the flakiness — and the region-dependent false green — disappears.

  • Why does it exist? Because human time is a calendar-and-clock kludge — zones, DST tears, leap years, leap seconds, and a fixed-width epoch counter — and none of that irregularity is captured by treating a timestamp as an ordinary number.
  • What problem does it solve? Storing UTC and converting only at the edge, plus testing with a frozen injected clock, replaces “it passed at my desk today” with checks at the exact instants that break — the DST gap, Feb 29, timestamp 0, and the 2038 wall — regardless of where or when the suite runs.
  • What are the trade-offs? You carry timezone-aware types everywhere, depend on a tzdata version that changes several times a year, and must convert at every display edge. The cost is discipline and a real time library; the payoff is that “it worked yesterday” stops being a bug report.
  • When should I avoid it? You never skip UTC storage or clock injection, but you scale calendar rigor to reach: a purely local, single-zone, never-persisted display value needs less machinery than anything stored, compared, scheduled, or shared across zones.
  • What breaks if I remove it? You ship code that stores naive local times (losing the instant), subtracts local times across DST (wrong durations), rejects Feb 29 or negative timestamps, confuses seconds with milliseconds, and passes CI in one region while failing in another — until the clocks change or the year turns and yesterday’s green suite is red.
  1. Why is storing UTC and converting only at the display edge the defensive default, and what specific information does a naive (zone-less) local datetime throw away?
  2. Describe the two DST discontinuities — the spring-forward gap and the fall-back overlap — and give one concrete bug each causes for schedules or durations.
  3. State the full Gregorian leap-year rule including the century exceptions, and explain why the naive year % 4 == 0 check ships anyway before failing decades later. Why does “a minute is 60 seconds” turn out to be false?
  4. What is the Year 2038 problem, why is it already affecting some systems today rather than only in 2038, and how do millisecond-vs-second confusion and negative (pre-1970) timestamps each corrupt time handling?
  5. Why is a test that calls the real now() both flaky and region-dependent, and what two disciplines (one about the clock, one about the timezone) make time-dependent tests deterministic?
Show answers
  1. UTC is a single, offset-free, DST-free timeline where every instant maps to exactly one value, so comparisons and subtractions are honest; converting only at the display edge keeps the ambiguity in one place. A naive local datetime throws away the zone/offset, so you can no longer reconstruct the actual instant, safely compare two datetimes, or subtract them to get a real duration — and you cannot even tell whether the value falls in a DST gap.
  2. Spring forward: a local hour (e.g. 02:00–02:59) is skipped and never exists, so an alarm or cron scheduled at 02:30 asks for an instant that didn’t happen (it errors, silently skips, or runs at the wrong time). Fall back: a local hour repeats, so a local time like 01:30 occurs twice and is ambiguous — events can be ordered wrong, or an hourly job fires twice or skips an hour. Durations break because “add one calendar day” across a DST boundary is 23 or 25 hours, not 24 — so calendar math and duration math give different answers.
  3. Leap if divisible by 4, except not if divisible by 100, except yes if divisible by 400 (2000 is leap; 1900 and 2100 are not). The naive year % 4 == 0 is correct for every year from 1901 to 2099 — the entire range most developers test — so it passes and only fails at century boundaries far in the future. “A minute is 60 seconds” is false because leap seconds are occasionally inserted (a 23:59:60 minute of 61 seconds) to keep clocks aligned with Earth’s rotation, so code hard-coding 60 s/minute or 86,400 s/day is wrong on those days.
  4. A signed 32-bit count of seconds since 1970 overflows at 2,147,483,647 seconds — 03:14:07 UTC on 19 January 2038 — wrapping to a negative value that reads as 1901. It affects systems today because any code computing timestamps that mature after Jan 2038 (long mortgages, bonds, flights) is already hitting the wall. Millisecond-vs-second confusion is off by 1000× (a valid time becomes the far future or 1970 depending on direction) and passes tests that only use “now” because “now” looks plausible in either unit; negative timestamps are legitimate pre-1970 dates, so rejecting negatives or using an unsigned type silently corrupts every earlier date.
  5. Calling now() reads a different value each run (so it can’t target the interesting instants and can be flaky) and is silently coupled to the host’s timezone (so it passes in your region and fails in another, or passes today and fails at a DST boundary). Make it deterministic by (a) injecting the clock — pass now as a parameter or use a frozen fake clock — so you can stand at exact boundaries, and (b) pinning the timezone in the test rather than trusting the host locale, so the outcome doesn’t change with the machine’s region.