Time Zones: Stop Moving the Clock

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: ·

01An instant is not a local time

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
Name the value before touching itAsk: is this when something happened, what a clock should show, a calendar date, or a recurring human rule? If the product requirement says only “send at nine,” the code is not ready. Ask nine where, and whether nine should stay nine after daylight saving changes.

02UTC, offsets, and IANA zones are different

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.

03ISO 8601: make the offset visible

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"
API contract, not parser rouletteSpecify whether each field is an instant, local datetime, date, or IANA zone. Reject offset-free strings where an instant is required. Do not document every temporal field as “ISO date”; that phrase hides the distinction that causes the bug.

04DST creates a gap and a fold

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
“It is exactly one hour wrong”Example symptom: 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.

05Store the meaning, not one universal shape

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.

06Future schedules need intent plus rules

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
Choose policy; do not inherit a library accidentFor a nonexistent time, reject it or shift it forward and tell the user. For an ambiguous time, choose the earlier or later occurrence and record that choice. Library defaults differ, so an implicit policy can change when code moves between JavaScript, Python, and the database.

07A date-only value must stay date-only

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
One-day error, fixed step by stepOne: inspect the raw API value before parsing. Two: if it is date-only, stop constructing a 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.

08Unix timestamps: seconds versus milliseconds

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 valueRangeError: 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.

09Debug across Python, JavaScript, APIs, and SQL

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)
Naive versus aware, fixed step by stepFor 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.

10Time-zone cheat sheet

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.

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.