Timestamps and ISO 8601 Explained
How to handle dates and times across systems without timezone disasters.
Every timezone bug in production traces back to one of a small number of conceptual mistakes: confusing an instant with a wall-clock time, confusing an offset with a timezone, or trusting a parser to guess what you meant. The formats themselves — Unix timestamps and ISO 8601 — are the easy part. Knowing which representation a given piece of data needs is the actual skill.
Instants vs Wall-Clock Times
An instant is a point on the global timeline: "the moment the payment cleared." A wall-clock time is what a local clock displays: "the meeting is at 09:00 in Seoul." These are different data types. An instant is safely stored as UTC forever. A future wall-clock event is not — more on that below, and it is the most commonly shipped timezone bug.
Unix Time
1745939938 seconds (classic Unix)
1745939938123 milliseconds (JavaScript Date.now())
1745939938123456 microseconds (many databases)
Unix time counts seconds since 1970-01-01T00:00:00Z, ignoring leap seconds — each day is exactly 86,400 seconds by definition, and leap seconds are absorbed by making some second twice as long (or smeared across hours, as Google and AWS clocks do). It is timezone-free, compact, and trivially sortable. Its risks are unit confusion — a seconds value read as milliseconds lands in January 1970, a milliseconds value read as seconds lands in the year 57,000 — and unreadability in logs. If a timestamp field can carry either unit, you have a bug factory; standardize on one unit and name fields accordingly (created_at_ms).
ISO 8601 and RFC 3339
2026-04-30T15:32:18Z UTC instant
2026-04-30T15:32:18.123+09:00 with millis and offset
2026-04-30 calendar date, no time
ISO 8601 is a large standard covering durations (P3Y6M4D), intervals, week dates (2026-W18-4), and ordinal dates. What APIs almost always mean is RFC 3339, the internet profile: full date and time, extended notation with hyphens and colons, and a mandatory offset or Z. Two useful details from the spec: a lowercase t or z is legal but conventionally avoided, and -00:00 historically signaled "offset unknown" — normalize to Z and uppercase separators when emitting.
The killer property of RFC 3339 strings: chronological order equals lexicographic order (within a single offset), so string-sorted logs are time-sorted logs.
Offsets Are Not Timezones
+09:00 is an offset — a fixed number. Asia/Seoul is a timezone — a set of political rules from the IANA tz database that maps wall-clock times to offsets, including all historical and scheduled DST transitions. The tz database updates several times a year because governments change the rules on short notice.
This is why future events need the timezone name, not the offset. Store "2027-03-10 09:00 Europe/Berlin", not "2027-03-10T09:00+01:00" — if Germany abolishes DST before then, the stored offset silently pins the meeting to the wrong hour. Past instants, by contrast, are safest as plain UTC.
DST Edge Cases Worth Coding For
- Skipped times: during a spring-forward, 02:30 local simply does not exist. Schedulers must define a policy (shift forward is typical).
- Repeated times: during fall-back, 01:30 occurs twice; an offset or a disambiguation flag is required to pick one.
- Date arithmetic: "add one day" and "add 24 hours" differ on transition days. Calendar math must be done in the event's timezone, never on raw epochs.
Practical Storage Rules
- Database instants:
timestamptzin Postgres (note: it stores UTC and converts on the way in and out — it does not remember the original zone) or a BIGINT of epoch milliseconds. - APIs and JSON: RFC 3339 strings with explicit
Zor offset. - Future scheduled events: wall-clock time plus IANA zone name, materialized to UTC only at execution time.
- Birthdays and other date-only values: plain
YYYY-MM-DD, never midnight-in-some-zone. Attaching a time is how birthdays shift by a day for users west of Greenwich.
JavaScript-Specific Traps
Date parsing has a spec-mandated inconsistency: new Date('2026-04-30') is interpreted as UTC midnight, while new Date('2026-04-30T09:00') — with a time but no offset — is interpreted as local time. Add explicit offsets and both problems vanish. For rendering, let the platform do the locale work:
new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'Asia/Tokyo'
}).format(new Date(1745939938123));
The Temporal API (Temporal.Instant, Temporal.ZonedDateTime, Temporal.PlainDate) finally encodes the instant/wall-clock/date-only distinction in the type system; it is the correct target for new code as runtimes ship it, with date-fns or Luxon as today's practical stand-ins.
Measuring Durations: The Wrong Clock and the Right One
Wall-clock timestamps are for recording when something happened, not for measuring how long it took. System clocks step backwards during NTP corrections and jump during manual changes, so Date.now() deltas occasionally go negative in production traces. Every platform provides a monotonic clock for intervals: performance.now() in JavaScript, time.monotonic() in Python, CLOCK_MONOTONIC in POSIX. The same reasoning applies across machines: client clocks drift by seconds to minutes, so never compare a client-generated timestamp against a server-generated one to compute latency — issue both from the same clock, or measure round-trips from a single vantage point.
Convert between epochs, ISO strings, and human-readable forms with the Timestamp Converter at sdk.is/timestamp-converter.