Guide

Date, time and timezones explained: UTC, DST and ISO 8601

Every production system eventually mishandles a clock. A meeting reminder fires an hour early after daylight saving starts. A billing job double-charges because "midnight local" does not exist on spring-forward night. A blockchain explorer shows a transaction timestamp that confuses users in Tokyo and Toronto. The root cause is almost always the same: developers mixed up an instant (a single point on the universal timeline) with civil time (what a wall clock reads in a particular region). This guide explains the mental model that prevents those bugs — how to store UTC, parse ISO 8601, survive DST transitions, and test datetime logic across JavaScript, Python, and databases.

Instants, civil time, and why they are not interchangeable

An instant is unambiguous: "2026-06-07T18:00:00Z" names one moment everywhere on Earth. Civil time adds a timezone offset or named zone: "June 7, 2026 at 2:00 PM Eastern" only becomes an instant once you know whether Eastern is on standard time (UTC-5) or daylight time (UTC-4). The same wall-clock reading can map to two different instants during a fall-back overlap, or to no instant at all during a spring-forward gap.

UTC (Coordinated Universal Time) is the global reference for instants. It does not observe daylight saving. Servers, databases, and blockchains should store and compare instants in UTC (or as Unix epoch seconds/milliseconds, which are UTC-based by definition). Convert to local civil time only at the presentation layer — UI labels, calendar widgets, email copy — using the user's timezone preference.

Three rules that prevent most incidents:

  • Store UTC, display local. Never persist "2:30 PM America/New_York" without also storing the offset or zone ID.
  • Never do arithmetic on civil dates. "Add one day" to March 8 at 2:00 AM local can land on March 9 or skip an hour depending on DST. Add duration to instants instead.
  • Use named zones, not fixed offsets, for future events. UTC-5 is wrong half the year in New York. Use IANA identifiers like America/New_York.

ISO 8601 and Unix timestamps

ISO 8601 is the interchange format APIs should speak. Key patterns:

  • 2026-06-07T18:00:00Z — instant in UTC (the Z means Zulu/UTC).
  • 2026-06-07T14:00:00-04:00 — instant with explicit offset (same moment as the UTC example above during Eastern daylight time).
  • 2026-06-07 — a calendar date without time; not an instant until you attach a timezone rule (dangerous for global products).

Always include the offset or Z in API responses. Parsing a string without offset using the server's local timezone is a classic source of staging-vs-production divergence.

Unix timestamps count seconds (or milliseconds) since 1970-01-01T00:00:00Z. They are compact, sortable, and timezone-agnostic — ideal for logs, caches, and on-chain block times. Downsides: not human-readable, year-2038 limits on 32-bit signed seconds, and millisecond vs second confusion between JavaScript (Date.now()) and many Unix tools. Document which unit your API uses and never mix them silently.

Daylight saving: gaps, overlaps, and political surprises

Most of North America and Europe shift clocks twice a year. Two edge cases break naive code:

Spring forward (gap)

On the US "spring forward" Sunday, 2:00 AM becomes 3:00 AM. Local times between 2:00 and 2:59 do not exist. Scheduling "2:30 AM" that day should error or shift — not silently pick the wrong instant.

Fall back (overlap)

On the fall-back Sunday, 1:00 AM happens twice — once in daylight time, once in standard time. "1:30 AM" is ambiguous. Your library must know which occurrence the user meant, usually by offset or by "first" vs "second" disambiguation.

DST rules also change by government decree. Egypt, Turkey, and US states have altered schedules with short notice. That is why production systems rely on the IANA Time Zone Database (tzdata), updated several times per year, rather than hard-coded offset math.

JavaScript: Date pitfalls and the Temporal migration

The built-in Date object is a notorious footgun. It stores one instant internally but many methods (getHours(), getMonth()) interpret that instant in the runtime's local timezone — fine on a laptop in Chicago, wrong in CI running UTC.

Common mistakes:

  • new Date('2026-06-07') — parsed as UTC midnight in ES5 but as local midnight in some engines for date-only strings. Prefer full ISO with offset.
  • date.setHours(0, 0, 0, 0) — "start of day" in local time; not portable across users.
  • Comparing Date objects with == instead of getTime().
  • Formatting with manual string concatenation instead of Intl.DateTimeFormat.

For display, use Intl:

new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York',
  dateStyle: 'medium',
  timeStyle: 'short'
}).format(new Date('2026-06-07T18:00:00Z'));

The Temporal proposal (available in modern browsers and Node via polyfill) replaces Date with explicit types: Temporal.Instant for timeline points, Temporal.ZonedDateTime for zone-aware civil time, Temporal.PlainDate for calendar dates without time. The API makes illegal DST times throw instead of guessing — the behavior you want in financial and scheduling apps.

Because datetime work still runs on the main thread, heavy parsing in hot paths can hurt Interaction to Next Paint. Cache formatted strings and defer bulk conversions to Web Workers when profiling shows a problem.

Python, Node.js backends, and databases

In Python, prefer datetime with explicit timezone info (datetime.now(timezone.utc)) or the third-party zoneinfo module (stdlib since 3.9) over naive datetimes. Never compare naive and aware objects.

Node.js services should serialize JSON with ISO strings including Z, set TZ=UTC in containers, and log epoch milliseconds with a documented field name. Cron jobs scheduled in "local server time" break when the host timezone differs from the business timezone.

Database patterns:

  • PostgreSQL: timestamptz stores UTC internally; casts display to the session timezone. Use timestamptz for instants, date for calendar dates without time.
  • MySQL: DATETIME is naive; TIMESTAMP converts to UTC on write. Know which you chose and stay consistent.
  • SQLite: often stores ISO text or integers; enforce UTC at the application layer.

For recurring events ("every Tuesday at 9 AM in London"), store the rule (RRULE or custom) plus the zone ID — not a series of precomputed UTC instants that break when Parliament moves DST.

API design and cross-timezone products

REST APIs should accept and return instants in UTC with ISO 8601. If users submit local civil times (booking forms), require a timezone field and validate DST edges server-side. Return both the instant and the user's intended local representation when disputes matter.

Pagination cursors based on local midnight ("transactions since start of today") are undefined for global users unless "today" is scoped per user zone. Prefer since=2026-06-07T00:00:00-04:00 or pass the user's IANA zone as a query parameter.

Blockchain UIs face a special case: block timestamps are instants (Unix seconds), but users expect local labels. Always label the timezone in the UI ("14:32 UTC" or "10:32 EDT") so a Solana transaction viewed in Berlin and Boston does not look like a bug.

Testing: fixtures that catch DST bugs

Unit tests written only in July miss half the year's edge cases. Maintain a small fixture table and run it in CI:

  • US spring-forward gap: America/New_York, 2026-03-08 02:30 (invalid)
  • US fall-back overlap: America/New_York, 2026-11-01 01:30 (ambiguous)
  • UTC boundary: 2026-12-31T23:59:59Z rolling into new year
  • Leap second / leap day: 2024-02-29 for calendar-date logic
  • Southern hemisphere DST: Australia/Sydney (opposite season to US)

Property-based tests that add random durations to instants and round-trip through format parsers often find offset bugs faster than hand-picked examples. Freeze the clock in tests (jest.useFakeTimers, Python freezegun) but still vary the timezone environment variable.

Production checklist

  • Store and compare instants in UTC (or Unix epoch); convert to local only for display.
  • Serialize ISO 8601 with explicit Z or offset in every API field.
  • Use IANA timezone names (Europe/London), not fixed UTC offsets, for future local times.
  • Validate DST gaps and overlaps; never assume every local time exists.
  • Run containers and CI in UTC; document the business timezone separately.
  • Pick timestamptz (or equivalent) in new database schemas for events.
  • Add DST fixture dates to your test suite; update tzdata when OS packages release.
  • Label timezones in user-facing timestamps so global audiences trust the UI.

Related reading