The Most Important Principle: Timezone ≠ Offset
A UTC offset is a static number: +08:00, -05:00, +05:30.
A timezone is a named rule set that determines what offset applies at any given moment in history — past, present, and future. It encodes DST transitions, historical offset changes, and political decisions.
"Asia/Shanghai" → always UTC+08:00 (no DST since 1991)
"America/New_York" → UTC-05:00 in winter, UTC-04:00 in summer
"Europe/London" → UTC+00:00 in winter, UTC+01:00 in summer
"Pacific/Apia" → was UTC-11:00 until 2011, then jumped to UTC+13:00
The error: code that stores offset = -5 for "US Eastern Time" will be wrong for half the year. Code that stores "America/New_York" is correct forever because the timezone database encodes the DST rules.
The IANA Timezone Database
The IANA timezone database (also called "tz database", "Olson database", or "zoneinfo") is the authoritative source for timezone rules worldwide.
Architecture
/usr/share/zoneinfo/ (compiled binary TZif files)
├── Africa/
│ ├── Cairo
│ ├── Nairobi
│ └── ...
├── America/
│ ├── New_York
│ ├── Chicago
│ ├── Los_Angeles
│ └── ...
├── Asia/
│ ├── Shanghai
│ ├── Tokyo
│ ├── Kolkata
│ └── ...
├── Europe/
│ ├── London
│ ├── Paris
│ ├── Moscow
│ └── ...
└── Pacific/
├── Auckland
├── Apia
└── ...
Naming Convention: Continent/City
IANA names use the most populous city in each zone, not country names:
Asia/Shanghai(notAsia/ChinaorCST)America/New_York(notUS/EasternorEST)Europe/London(notEurope/UKorGMT)
This avoids ambiguity: "CST" means China Standard Time, Central Standard Time (US), or Cuba Standard Time depending on context. IANA names are unambiguous.
Database Maintenance
The IANA database is maintained by a volunteer community and released multiple times per year. Each release (e.g., 2024a, 2024b) includes:
- New timezone rules enacted by governments
- Corrections to historical data
- Changes to DST transition dates
Real examples of changes:
- 2011: Samoa skipped December 30 entirely to switch from UTC−11 to UTC+13
- 2014: Russia moved from 11 time zones to 9, then back to 11 in 2016
- 2022: Jordan canceled DST permanently (staying at UTC+03:00)
- 2023: Lebanon had two timezones simultaneously for 12 days due to political dispute
This is why timezone handling requires a regularly updated database, not hardcoded rules.
DST Transitions: Gaps and Folds
The Gap (Spring Forward)
When clocks spring forward, a range of local times doesn't exist:
US Eastern, March 10, 2024:
1:59:59 AM EST (UTC-05:00)
→ clock jumps to →
3:00:00 AM EDT (UTC-04:00)
2:30 AM on this date DOES NOT EXIST
What happens if you create "2024-03-10 02:30:00 America/New_York"?
- Some libraries throw an error
- Some shift to 3:30 AM EDT
- Some shift to 1:30 AM EST
- Undefined behavior if your code doesn't handle it
The Fold (Fall Back)
When clocks fall back, a range of local times occurs twice:
US Eastern, November 3, 2024:
1:59:59 AM EDT (UTC-04:00)
→ clock falls back to →
1:00:00 AM EST (UTC-05:00)
1:30 AM on this date exists TWICE:
1:30 AM EDT = 05:30 UTC
1:30 AM EST = 06:30 UTC
If you store "2024-11-03 01:30:00 America/New_York" without indicating which occurrence, you have an ambiguous timestamp — it could mean either of two UTC instants one hour apart.
Python 3.9+ Handling
from datetime import datetime
from zoneinfo import ZoneInfo
eastern = ZoneInfo("America/New_York")
# The gap: 2:30 AM doesn't exist on March 10, 2024
# datetime doesn't raise — it picks the post-transition offset
gap_time = datetime(2024, 3, 10, 2, 30, tzinfo=eastern)
print(gap_time) # 2024-03-10 02:30:00-04:00 (EDT, not EST)
print(gap_time.utctimetuple()) # effectively 3:30 AM EDT
# The fold: 1:30 AM exists twice on November 3, 2024
fold0 = datetime(2024, 11, 3, 1, 30, tzinfo=eastern, fold=0) # first occurrence (EDT)
fold1 = datetime(2024, 11, 3, 1, 30, tzinfo=eastern, fold=1) # second occurrence (EST)
print(fold0.utcoffset()) # -04:00 (EDT)
print(fold1.utcoffset()) # -05:00 (EST)
Modern APIs
Python: zoneinfo (3.9+, Replaces pytz)
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# Create timezone-aware datetime
now_utc = datetime.now(timezone.utc)
now_tokyo = now_utc.astimezone(ZoneInfo("Asia/Tokyo"))
now_ny = now_utc.astimezone(ZoneInfo("America/New_York"))
print(f"UTC: {now_utc.isoformat()}")
print(f"Tokyo: {now_tokyo.isoformat()}")
print(f"New York: {now_ny.isoformat()}")
# Convert between timezones
meeting_tokyo = datetime(2024, 6, 15, 10, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
meeting_ny = meeting_tokyo.astimezone(ZoneInfo("America/New_York"))
print(f"10:00 Tokyo = {meeting_ny.strftime('%H:%M')} New York") # 21:00 (previous day)
Why zoneinfo over pytz: pytz has non-standard API quirks (you must use localize() instead of passing tzinfo to constructor). zoneinfo follows PEP 615 and works with standard datetime semantics.
JavaScript: Intl.DateTimeFormat (Current Standard)
function formatInTimezone(date, timezone) {
return new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZoneName: 'short'
}).format(date);
}
const now = new Date();
console.log(formatInTimezone(now, 'America/New_York'));
console.log(formatInTimezone(now, 'Asia/Tokyo'));
console.log(formatInTimezone(now, 'Europe/London'));
// Get offset for a specific timezone at a specific instant
function getOffset(timezone, date = new Date()) {
const utcStr = date.toLocaleString('en-US', { timeZone: 'UTC' });
const tzStr = date.toLocaleString('en-US', { timeZone: timezone });
return (new Date(tzStr) - new Date(utcStr)) / 3600000;
}
JavaScript: Temporal (Stage 3 Proposal)
Temporal is the forthcoming replacement for the Date object, with first-class timezone support:
// Temporal (available in polyfill: @js-temporal/polyfill)
import { Temporal } from '@js-temporal/polyfill';
// Current instant
const now = Temporal.Now.instant();
// Convert to timezone
const tokyo = now.toZonedDateTimeISO('Asia/Tokyo');
const ny = now.toZonedDateTimeISO('America/New_York');
console.log(tokyo.toString());
// 2024-06-15T22:30:00+09:00[Asia/Tokyo]
// Create a specific local time in a timezone
const meeting = Temporal.ZonedDateTime.from({
timeZone: 'America/New_York',
year: 2024, month: 6, day: 15,
hour: 14, minute: 0
});
// Convert to another timezone
const meetingTokyo = meeting.withTimeZone('Asia/Tokyo');
console.log(meetingTokyo.hour); // 3 (next day)
// DST-safe arithmetic
const laterMeeting = meeting.add({ hours: 24 });
// Adds 24 hours of elapsed time, correctly handling any DST transitions
Temporal distinguishes between:
Temporal.Instant— a point on the UTC timeline (no timezone)Temporal.ZonedDateTime— an instant observed in a specific timezoneTemporal.PlainDateTime— a "wall clock" reading with no timezone (local time)
Go: time.Location
package main
import (
"fmt"
"time"
)
func main() {
// Load timezone
tokyo, _ := time.LoadLocation("Asia/Tokyo")
ny, _ := time.LoadLocation("America/New_York")
now := time.Now().UTC()
fmt.Println("UTC: ", now.Format(time.RFC3339))
fmt.Println("Tokyo: ", now.In(tokyo).Format(time.RFC3339))
fmt.Println("New York:", now.In(ny).Format(time.RFC3339))
// Create time in a specific timezone
meeting := time.Date(2024, 6, 15, 14, 0, 0, 0, ny)
fmt.Println("Meeting in Tokyo:", meeting.In(tokyo).Format("15:04"))
}
Common Production Bugs
1. Storing Local Time Without Timezone
# BUG: stored "2024-03-10 02:30:00" in a VARCHAR column
# This time doesn't exist in America/New_York — what does it mean?
# Nobody knows. The data is corrupted.
# FIX: store UTC timestamps or timestamps with timezone offset
# PostgreSQL: TIMESTAMP WITH TIME ZONE (stored as UTC internally)
# Application: always convert to UTC before storage
2. Using Abbreviations as Identifiers
// BUG: "CST" is ambiguous
const tz = "CST"; // China Standard Time? Central Standard Time? Cuba Standard Time?
// FIX: use IANA identifiers
const tz = "America/Chicago"; // unambiguous
3. Assuming Offsets Are Integers
# These are real UTC offsets:
# UTC+05:30 India (IST)
# UTC+05:45 Nepal
# UTC+08:45 Western Australia (Eucla, informal)
# UTC+12:45 Chatham Islands (New Zealand)
# UTC+09:30 South Australia (Adelaide)
# BUG: storing offset as integer hours
offset_hours = 5 # Loses the :30 for India
# FIX: store offset as total minutes or use IANA name
offset_minutes = 330 # India: 5*60 + 30
4. Hardcoding DST Rules
// BUG: assuming US DST rules apply globally or don't change
function isDST(date) {
const mar = new Date(date.getFullYear(), 2, 1);
const nov = new Date(date.getFullYear(), 10, 1);
return date > mar && date < nov;
}
// FIX: let the timezone database handle it
function isDST(date, timezone) {
const jan = new Date(date.getFullYear(), 0, 1);
const jul = new Date(date.getFullYear(), 6, 1);
const janOffset = getOffset(timezone, jan);
const julOffset = getOffset(timezone, jul);
const currentOffset = getOffset(timezone, date);
const standardOffset = Math.min(janOffset, julOffset);
return currentOffset !== standardOffset;
}
5. "24 Hours From Now" vs "Same Time Tomorrow"
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
eastern = ZoneInfo("America/New_York")
# March 9, 2024 at 10:00 AM EST
saturday = datetime(2024, 3, 9, 10, 0, tzinfo=eastern)
# "24 hours later" — add 24 hours of elapsed time
elapsed = saturday + timedelta(hours=24)
print(elapsed) # 2024-03-10 11:00:00-04:00 (11 AM EDT, not 10 AM!)
# "Same time tomorrow" — keep the wall clock time
# Use replace() or construct new datetime
sunday_same_time = datetime(2024, 3, 10, 10, 0, tzinfo=eastern)
print(sunday_same_time) # 2024-03-10 10:00:00-04:00 (10 AM EDT)
On DST transition days, "24 hours from now" and "same time tomorrow" give different results. Which one you want depends on the use case:
- Recurring meetings: same wall-clock time (10 AM every day)
- Medication schedules: same elapsed time (every 24 hours)
- Financial settlement: defined by contract (usually UTC)
ISO 8601: The Interchange Format
2024-06-15T14:30:00Z UTC (Z suffix)
2024-06-15T14:30:00+00:00 UTC (explicit offset)
2024-06-15T10:30:00-04:00 Eastern Daylight Time
2024-06-15T23:30:00+09:00 Japan Standard Time
All four represent the SAME instant if the offsets are correct.
Rules for Interchange
- Always include offset — bare
2024-06-15T14:30:00is ambiguous (local time where?) - Prefer Z or +00:00 for storage — convert to UTC before persisting
- Include timezone name for human display — offset alone doesn't tell you DST rules
- Use RFC 3339 profile for APIs — it's ISO 8601 with restrictions that eliminate ambiguity
Database Storage Patterns
PostgreSQL
-- TIMESTAMP WITH TIME ZONE: stored as UTC, displayed in session timezone
-- This is what you almost always want
CREATE TABLE events (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
starts_at TIMESTAMPTZ NOT NULL, -- stored as UTC
timezone TEXT NOT NULL -- IANA name for display context
);
-- Insert with explicit timezone
INSERT INTO events (name, starts_at, timezone)
VALUES ('Conference', '2024-06-15 09:00:00-04:00', 'America/New_York');
-- Query with timezone conversion
SELECT name, starts_at AT TIME ZONE timezone AS local_time
FROM events;
The Pattern
Store two things:
- The UTC instant (TIMESTAMPTZ) — when the event occurs on the absolute timeline
- The IANA timezone name (TEXT) — the context for displaying/recurring the event
Why both? Because if the timezone rules change (government cancels DST), you need the timezone name to recalculate the wall-clock time. The UTC instant alone tells you when but not the intended local meaning.
Summary
Timezone engineering requires respecting these realities:
- Timezone ≠ offset — a timezone is a historical rule set; an offset is a number at a single instant
- Use IANA names (
America/New_York), never abbreviations (EST) or bare offsets (-5) - Store UTC — convert to local only at display time
- DST creates gaps and folds — your code must handle times that don't exist and times that exist twice
- The database changes — timezone rules are political decisions; keep tzdata updated
- "Same time tomorrow" ≠ "+24 hours" — know which semantics your feature requires
- Use modern APIs — Python zoneinfo (not pytz), Temporal (not Date), Go time.Location