datatypes · level 6

Dates & Times

Wall-clock vs monotonic, UTC always, ISO-8601, and the rules that save you from DST.

200 XP

Dates & Times

Time looks simple right up until you ship a system that touches more than one timezone, runs across a daylight-saving boundary, or has to measure a duration. Then you discover that there are at least three different notions of "time", that the standard library lies, and that 2:30 a.m. doesn't always exist. The trick to staying sane is to learn the rules in advance, not as outage post-mortems.

Analogy

Imagine three different stopwatches at a relay race. The first is a wristwatch on a runner — it shows wall-clock time, perfect for "what time is it?" but useless for measuring laps because the kid setting up the race nudged it ten minutes forward. The second is a track timer — monotonic, only goes up, the only way to time the runners. The third is a wall calendar in the office — it knows what month it is. They are not interchangeable. Pick the wrong one and you end up either with a runner whose lap took -3 seconds, or a meeting scheduled for last Tuesday.

Three notions of "time"

Wall-clock time. The time on the kitchen wall. Drifts; corrects itself; jumps backward when NTP catches a few seconds of error; jumps forward in the spring. Good for "show this user the time" and "log when this happened in human terms." Bad for measuring how long anything took.

Monotonic time. A counter that only goes forward. Has no calendar meaning — you can't say "two o'clock monotonic." Good for measuring durations: timeouts, request latency, animation frames. Use it any time you compute t2 - t1.

// Don't:
const start = Date.now();
const elapsed = Date.now() - start;   // can go backward when NTP fires

// Do:
const start = performance.now();
const elapsed = performance.now() - start;   // immune

Calendar time. Year, month, day, hour, minute, second — and a timezone. The thing humans put on their calendar. Days are 23, 24, or 25 hours long depending on DST. Months are 28, 29, 30, or 31 days. Different rules in every country.

These three concepts are different types, even when languages don't enforce it. Mixing them is a class of bug you have to learn to spot.

The two rules of timezones

  1. Store UTC. Always. In the database, in logs, in API payloads. UTC has no political history, doesn't change for DST, and is the only timezone every system can agree on.
  2. Convert to the user's zone at the edge. When you display a value, format it in the viewer's timezone — not when you store, not when you process. Display is the only place local zones are relevant.
-- Postgres: TIMESTAMPTZ stores at UTC and converts on input/output. Use it.
CREATE TABLE events (
  id        bigserial primary key,
  occurred  timestamptz not null,         -- always UTC under the hood
  ...
);

The corollary: never use locale-aware comparison. '2026-04-28 09:00 EDT' < '2026-04-28 09:30 EST' looks true (09:00 < 09:30) but is actually false — EDT is one hour ahead of EST, so 09:00 EDT happens after 09:30 EST. The only safe comparison is on UTC instants.

Calendar values without a zone

Sometimes the time really is local — and staying local is the right behaviour. A meeting room in São Paulo, booked for 09:00 next Tuesday, should always be at 09:00 in São Paulo regardless of where the user viewing it lives. If you stored UTC and converted, then DST in Brazil would shift the meeting by an hour.

This is local time (a.k.a. "naive", "floating", "wall time"). It's a year-month-day-hour-minute, no timezone. JavaScript represents it as a string ("2026-12-15T09:00"); Python's datetime without a tzinfo is naive; Java's LocalDateTime is the type-safe answer.

The rule: only use local time when the value is genuinely tied to a place, not a person. Everything else: UTC.

ISO-8601 (and RFC 3339) over the wire

ISO-8601 is the international standard for date-time formats. RFC 3339 is the API-friendly subset most modern protocols use:

2026-04-28T15:32:00Z          UTC instant
2026-04-28T15:32:00.123Z      with millisecond precision
2026-04-28T08:32:00-07:00     local-with-offset (PDT)
2026-04-28                     date only
15:32:00                       time only
PT1H30M                        ISO-8601 duration: 1h 30m
2026-W18                       ISO week (week 18 of 2026)

Sortable as strings. Unambiguous about the zone. Universally parseable. Use this format on the wire; format for humans only when rendering.

DST and its two pathological cases

Twice a year, in countries that observe DST, calendar arithmetic breaks.

Spring forward. Clocks jump from 02:00 to 03:00. Local times like 02:30 don't exist. A library that tries to construct that time has to either reject it or silently shift to 01:30 or 03:30.

Fall back. Clocks roll back from 02:00 to 01:00. Local times like 01:30 happen twice. Asking "what's the UTC instant for 01:30 local?" has two valid answers. This is ambiguous time.

from zoneinfo import ZoneInfo
from datetime import datetime

ny = ZoneInfo("America/New_York")
ambiguous = datetime(2026, 11, 1, 1, 30, tzinfo=ny)   # 01:30 EDT or EST?
# Python uses fold=0 (first occurrence) by default; fold=1 picks the second.

The general rule: for any input from a user that's a calendar time in a zone where DST applies, store it as UTC at submission and document which fold you took. For data that crosses a DST boundary in the future (a recurring meeting), store the local time + zone, and re-compute the UTC instant when displaying.

Date math is harder than you think

Adding "1 month" to January 31 lands on February 31, which doesn't exist. Adding "1 day" across a DST boundary is sometimes 23 or 25 hours. Adding "1 year" to a leap-day birthday is undefined.

Languages disagree on the answers. JavaScript's Date silently rolls February 31 → March 3. Python's datetime raises. Use a library — date-fns, luxon, Temporal (TC39 Stage 3+) for JS; dateutil, pendulum, arrow for Python — that lets you specify the rule (clamp, overflow, raise).

import { Temporal } from "@js-temporal/polyfill";

const jan31 = Temporal.PlainDate.from("2026-01-31");
const feb = jan31.add({ months: 1 });   // 2026-02-28 (clamps by default)
const feb2 = jan31.add({ months: 1 }, { overflow: "reject" }); // throws

Temporal is the future of JS time — Stage 3 at the time of writing, polyfilled today, eventual replacement for Date. Worth investing in.

Common bugs

new Date(2026, 11, 25) is December. JavaScript months are 0-indexed (January = 0). Use ISO strings, not the constructor.

new Date("2026-04-28") is UTC midnight. Without a time component, the spec says UTC. With a time component but no zone, behaviour varies. Always include T and the zone or Z.

Date.parse("2026-04-28") returns a number. It's the milliseconds since epoch — useful but unintuitive.

Storing now() from the application server's local zone. The server might be in Etc/UTC today and America/New_York after a config change. Always now('UTC') or Date.now().

Comparing wall-clock times across daylight-saving boundaries. 09:30 EST < 09:00 EDT is true — but unhelpful. Convert to UTC first.

Sorting log lines by their displayed timestamp string. If the log mixes 2026-04-28T08:32-07:00 and 2026-04-28T15:32Z, naive string sort is wrong. Convert to UTC instant.

Trusting Date.now() for measuring durations. NTP fires, the value goes backwards by 3 seconds, your latency metric reports a negative number. Always performance.now() or time.monotonic().

Practical checklist

  • Use UTC everywhere except the display layer.
  • Use ISO-8601 / RFC 3339 strings on the wire.
  • Use performance.now() / time.monotonic() to measure durations.
  • Reach for Temporal (JS), zoneinfo (Python 3.9+), chrono (Rust) instead of language built-ins for arithmetic.
  • For recurring events tied to a place, store local time + IANA zone (Europe/London, not BST).
  • Test code paths near a DST boundary explicitly. The bugs hide there.
  • Never construct a Date from a 0-indexed month or a string with no zone.