Most datetime bugs begin by confusing a moment with a wall-clock reading. This guide gives each kind of time a name, then shows exactly what to store, send, schedule, and inspect when production is one hour or one day wrong.
🎙️ Published & recorded: ·
An instant is one point on the global timeline. A local datetime is what a wall clock displays in some place. “July twenty-fifth at nine” is not an instant until you add a time zone. The same instant can be Saturday afternoon in London and Sunday morning in Tokyo.
# One instant, three displays
2026-07-25T15:00:00Z UTC
2026-07-25T11:00:00-04:00 New York display
2026-07-26T00:00:00+09:00 Tokyo display
# Not enough information to identify an instant
2026-07-25 09:00
UTC is the reference timeline. An offset, such as minus four hours, describes one relationship to UTC at one instant. An IANA zone, such as America slash New York, is a rulebook containing historical and future offset changes. An offset is not a time zone. It cannot tell you what the offset will be next March.
UTC reference: Z or +00:00
-04:00 fixed offset, no DST rules
America/New_York IANA zone with rule history
Etc/GMT+4 fixed offset; sign is reversed by convention
EST ambiguous abbreviation; do not store it
Use IANA identifiers at product boundaries. “CST” can mean North American Central, China Standard, or Cuba Standard Time. Windows zone names are a separate naming system and often need explicit mapping. Guessing from a user's current offset is also wrong: many zones share an offset today and diverge later.
For an API instant, send an ISO eight-six-oh-one string with Z or a numeric offset. Z means UTC. The letter T separates date and time. Fractional seconds are optional. A datetime string without an offset is deliberately incomplete, and different runtimes may interpret it as local time, UTC, or reject it.
2026-07-25T15:04:05Z # unambiguous UTC instant
2026-07-25T11:04:05-04:00 # same style, explicit offset
2026-07-25 # calendar date, not midnight UTC
2026-07-25 11:04:05 # ambiguous; no offset or zone
# JavaScript: serialize an instant in UTC
new Date("2026-07-25T11:04:05-04:00").toISOString()
"2026-07-25T15:04:05.000Z"
When clocks spring forward, a range of local times never occurs. That is a gap. When clocks fall back, a range occurs twice. That is a fold. On March eighth, twenty twenty-six in New York, the clock jumps from one fifty-nine to three. Two thirty is imaginary. On November first, one thirty happens twice with two different offsets.
# America/New_York, 2026
2026-03-08 01:59:59-05:00
↓ next second
2026-03-08 03:00:00-04:00
2026-03-08 02:30 does not exist
2026-11-01 01:30:00-04:00 # first occurrence
2026-11-01 01:30:00-05:00 # second occurrence
Expected 09:00, got 10:00. One: log the instant, IANA zone, and resolved offset on both paths. Two: check whether one path added a fixed twenty-four hours or reused yesterday's offset. Three: add calendar days in the target zone for wall-clock schedules; add elapsed seconds only for durations. Four: test both DST transitions. Do not patch the output by subtracting one hour.For events that already happened, store an instant in a database type with clear UTC semantics, then convert for display. Also retain the original offset when audit or legal presentation requires it. My blunt position is that “store everything as UTC” is good advice for past events and bad advice for birthdays, opening hours, and future local schedules. Those values are not instants yet.
# PostgreSQL shapes
occurred_at timestamptz # instant; normalized internally
birth_date date # calendar date
opens_at time # local wall time, paired with business zone
starts_local timestamp # local intent
zone_id text # e.g. Europe/Paris
# Persist an explicit contract
{"occurredAt":"2026-07-25T15:04:05Z"}
{"startsLocal":"2027-03-28T09:00:00","timeZone":"Europe/Paris"}
PostgreSQL timestamp with time zone stores an instant, not the submitted zone name. MySQL and SQLite have different behavior. Read your database's actual type rules and set the connection or session time zone explicitly. Column names such as created_at_utc are cheap documentation.
A flight, appointment, or “every weekday at nine” belongs to a local rule in a named zone. Store the local date and time, the IANA zone, and the policy for gaps and folds. You may cache the next UTC instant for fast execution, but recalculate future occurrences when time-zone data changes. Governments change clock rules with less notice than your database retention period.
{
"localStart": "2027-10-31T01:30:00",
"timeZone": "Europe/London",
"foldPolicy": "later",
"gapPolicy": "shift-forward"
}
# Define recurrence in calendar terms
weekdays at 09:00 in Europe/London
not: every 86,400 seconds forever
Birthdays, invoice dates, and hotel checkout dates are calendar dates. Converting them to midnight UTC invents an instant. Display that invented instant west of UTC and the date moves backward. Keep YYYY-MM-DD as a date type or a validated string until a real business rule assigns a time and zone.
# The classic browser bug in an America/Los_Angeles environment
new Date("2026-07-25").toString()
"Fri Jul 24 2026 17:00:00 GMT-0700 ..."
Expected 2026-07-25, got 2026-07-24
# Correct date-only handling
const birthday = "2026-07-25"; # validate, store, display as a date
Date instant. Three: keep it in a date-only type throughout the API and database. Four: format year, month, and day without applying a time-zone conversion. Adding twelve hours is a fragile disguise, not a fix.A Unix timestamp counts elapsed time from the Unix epoch, usually ignoring leap seconds. Python commonly accepts seconds. JavaScript's Date constructor accepts milliseconds. Today's second timestamp has about ten digits; milliseconds has about thirteen. Inferencing by digit count is useful while debugging, but the API contract must name the unit.
1753455845 # seconds
1753455845000 # milliseconds
# JavaScript
new Date(seconds * 1000)
new Date(milliseconds)
# Python, aware UTC datetime
datetime.fromtimestamp(seconds, tz=timezone.utc)
RangeError: Invalid time value often appears when invalid input reaches toISOString(). One: log the original value and its type. Two: reject null, empty text, and NaN. Three: confirm seconds versus milliseconds. Four: construct the date and verify Number.isNaN(date.getTime()) is false before formatting. Do not catch the exception and emit today's date.Do not begin with formatting. Capture the value at every boundary: raw text, parsed type, epoch value, offset, IANA zone, database type, session zone, and final display zone. Compare instants as instants. Convert only at the edge. A screenshot of “three PM” is weak evidence; an ISO string plus zone and offset is useful evidence.
# Python: create aware values
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("America/New_York"))
TypeError: can't compare offset-naive and offset-aware datetimes
# JavaScript: inspect the instant, then display in a named zone
console.log(date.toISOString(), date.getTime())
new Intl.DateTimeFormat("en", {timeZone:"America/New_York",
dateStyle:"full", timeStyle:"long"}).format(date)
TypeError: can't compare offset-naive and offset-aware datetimes, one: print each value's repr and tzinfo. Two: decide what zone the naive source was intended to use; do not assume UTC because it is convenient. Three: attach that source zone with ZoneInfo, handling gap or fold policy. Four: convert both values to UTC and compare. replace(tzinfo=UTC) relabels the clock; it does not convert it.Systematic order: reproduce with one known instant; freeze the process, database, and browser zones; log raw input and epoch; inspect the API offset; inspect the SQL column and session zone; convert once for display; then add regression cases for UTC midnight and both DST transitions.
Use this decision table before choosing a type or writing a conversion.
Already happened? → instant; store UTC semantics
Display for a user? → instant + chosen IANA zone
Future local schedule? → local datetime + IANA zone + gap/fold policy
Birthday/invoice date? → date only; never invent midnight UTC
Recurring at 09:00? → calendar recurrence in its IANA zone
Elapsed for 24 hours? → duration, not “same time tomorrow”
API instant → 2026-07-25T15:04:05Z
API date → 2026-07-25
Zone → America/New_York, not EST
Unix input → unit stated: seconds or milliseconds
One hour wrong → DST rule, fixed offset, or double conversion
One day wrong → date-only parsed as an instant near UTC midnight
Wild historical result → seconds/milliseconds or stale zone data
Debug: raw → parsed type → epoch → offset → IANA zone
→ DB type/session zone → API string → display zone
The rule I keep: never convert a value until you can say what it means. UTC is a timeline, not a universal user interface. IANA zones are rules, not decorations. Dates are not midnights. Once those three distinctions survive your database and API boundaries, most datetime “mysteries” stop being mysterious.