Dates and Time Zones in Code: UTC, ISO 8601 and Common Bugs
Practice4 minInnovialabs editorial team
In short
Store instants in UTC, exchange them as ISO 8601 strings with an offset, and convert to local time only when displaying to a user, using an IANA zone such as Asia/Tashkent.
The short answer
Most date bugs come from mixing up two different things:
- an instant — a point on the global timeline, the same everywhere: "the order was created";
- a calendar date or local time — what a person sees on a clock in their city: "birthday on March 5", "meeting at 10:00 Tashkent time".
The rule is simple: store instants in UTC, exchange them as ISO 8601 with an explicit offset, and convert to local time only at the last step, when showing it to a user. Store calendar dates without a time as dates, not as midnight UTC.
ISO 8601: one format for exchange
ISO 8601 is the date notation that every language and database understands:
| Value | Example |
|---|---|
| Date | 2026-03-05 |
| Instant in UTC | 2026-03-05T07:30:00Z |
| Instant with offset | 2026-03-05T12:30:00+05:00 |
The Z means UTC. A string with neither Z nor an offset (2026-03-05T12:30:00) is local time in an unknown zone. It is best not to accept such strings in an API at all.
An offset is not a time zone
+05:00 is an offset, while Asia/Tashkent is an IANA zone: it carries the history of rules, including daylight saving time (DST) and legal changes. Tashkent currently has no DST, but your users in Europe or the US do. If you stored only the offset, you cannot correctly compute "the same hour next week" after the clocks change.
So for recurring events and schedules, store local time plus the zone name; for facts that already happened, store the instant in UTC.
Common bugs
- Off-by-one day. A birthday saved as
2026-03-05T00:00:00Zshows up as March 4 for a user with a negative offset. Fix: a date-only type. - String parsing in JavaScript.
new Date("2026-03-05")is parsed as midnight UTC, whilenew Date("2026-03-05T00:00")is midnight in the browser's zone. One character changes the result. - Zero-based months. In the JS
Date, January is0. - "Plus 24 hours" instead of "plus 1 day". On a DST transition day, a day lasts 23 or 25 hours.
- Server time. The server runs in a different zone than the developer, and the "today" report does not start at midnight. Keep servers and databases in UTC.
- Comparing strings with different offsets.
12:30+05:00and07:30Zare the same instant but not equal strings.
How to do it in code
Displaying in a given zone in JavaScript uses the built-in Intl:
const d = new Date("2026-03-05T07:30:00Z");
new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Tashkent",
dateStyle: "medium",
timeStyle: "short",
}).format(d); // 5 Mar 2026, 12:30
For arithmetic and formatting, date-fns with date-fns-tz (formatInTimeZone, fromZonedTime) works well. Python has the standard zoneinfo module:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc) # instant in UTC
local = now.astimezone(ZoneInfo("Asia/Tashkent"))
print(local.isoformat())
Avoid "naive" datetime objects without tzinfo: they cannot be compared correctly with aware values.
Checklist for APIs and databases
- Store instants in a zone-aware type (in PostgreSQL,
timestamptz) and keep the connection session in UTC. - Store calendar dates in a
datetype, nottimestamp. - The API accepts and returns ISO 8601 with
Zor an offset; reject strings without one. - For schedules, store local time and the IANA zone name as separate fields.
- Take the user's zone from their profile or the browser (
Intl.DateTimeFormat().resolvedOptions().timeZone), not from guessing by IP. - Compute period boundaries ("per day", "per month") in the user's zone, then convert to UTC for the query.
- Write date tests around DST transitions and month ends.
- Do not store Unix time in seconds in one place and milliseconds in another; agree on one.