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.

code
"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

code
/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 (not Asia/China or CST)
  • America/New_York (not US/Eastern or EST)
  • Europe/London (not Europe/UK or GMT)

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:

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

code
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

python
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)

python
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)

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

javascript
// 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 timezone
  • Temporal.PlainDateTime — a "wall clock" reading with no timezone (local time)

Go: time.Location

go
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

python
# 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

javascript
// 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

python
# 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

javascript
// 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"

python
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

code
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

  1. Always include offset — bare 2024-06-15T14:30:00 is ambiguous (local time where?)
  2. Prefer Z or +00:00 for storage — convert to UTC before persisting
  3. Include timezone name for human display — offset alone doesn't tell you DST rules
  4. Use RFC 3339 profile for APIs — it's ISO 8601 with restrictions that eliminate ambiguity

Database Storage Patterns

PostgreSQL

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

  1. The UTC instant (TIMESTAMPTZ) — when the event occurs on the absolute timeline
  2. 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:

  1. Timezone ≠ offset — a timezone is a historical rule set; an offset is a number at a single instant
  2. Use IANA names (America/New_York), never abbreviations (EST) or bare offsets (-5)
  3. Store UTC — convert to local only at display time
  4. DST creates gaps and folds — your code must handle times that don't exist and times that exist twice
  5. The database changes — timezone rules are political decisions; keep tzdata updated
  6. "Same time tomorrow" ≠ "+24 hours" — know which semantics your feature requires
  7. Use modern APIs — Python zoneinfo (not pytz), Temporal (not Date), Go time.Location