The UTC Everywhere Pattern

May 28, 20266 min read

Timezone bugs are among the most insidious in software. They do not crash your application — they silently corrupt data. A meeting scheduled for 3:00 PM shows up at 2:00 PM. A daily report runs twice or not at all. A subscription expires a day early or a day late. The root cause in nearly every case is the same: the system stored or computed time in a local timezone instead of UTC. The "UTC Everywhere" pattern eliminates an entire class of these bugs with one architectural rule.

The Core Principle

Store UTC. Compute in UTC. Transmit UTC. Convert to local time only at the moment of display.

This principle applies across every layer of your stack:

LayerWhat to DoWhat to Avoid
DatabaseStore timestamps as TIMESTAMPTZ (UTC internally)TIMESTAMP without timezone
BackendAccept and return ISO 8601 with Z suffixParsing timestamps without offsets
APIUse 2026-05-28T14:30:00ZPassing local-time strings
FrontendConvert UTC to local only for renderingStoring local timestamps in state
LogsWrite timestamps in UTCLogging in server-local time

Database Layer

PostgreSQL's TIMESTAMPTZ type stores all timestamps internally as UTC. When you read the value, it returns the timestamp converted to the session's timezone. When you write a value with an offset, it converts to UTC before storing.

-- Correct: TIMESTAMPTZ forces UTC storage
CREATE TABLE events (
    id SERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    scheduled_at TIMESTAMPTZ NOT NULL
);

-- Wrong: TIMESTAMP stores the literal value, no timezone awareness
CREATE TABLE events_bad (
    created_at TIMESTAMP  -- Stores "2026-05-28 14:30:00" as-is
);

MySQL's DATETIME type does not store timezone information — it is a literal date-time string. Use TIMESTAMP instead (stored as UTC, converted to session timezone on read), or better yet, store an integer Unix epoch.

API Layer

Your API contract should require UTC timestamps in all request and response payloads:

// Request: schedule a meeting
{
  "title": "Sprint Review",
  "start_time": "2026-05-28T14:30:00Z",
  "duration_minutes": 60
}

// Response
{
  "id": "evt_abc123",
  "start_time": "2026-05-28T14:30:00Z",
  "end_time": "2026-05-28T15:30:00Z"
}

If clients send local times, your server must convert immediately:

from datetime import datetime, timezone

def parse_client_time(iso_string: str, client_tz: str) -> datetime:
    """Parse a local-time ISO string assuming the given timezone, return UTC."""
    from zoneinfo import ZoneInfo
    tz = ZoneInfo(client_tz)
    local_dt = datetime.fromisoformat(iso_string).replace(tzinfo=tz)
    return local_dt.astimezone(timezone.utc)

Frontend Layer

The browser's Intl.DateTimeFormat API converts UTC to the user's local timezone reliably:

function formatLocalTime(utcISOString) {
  const date = new Date(utcISOString);
  return new Intl.DateTimeFormat('en-US', {
    dateStyle: 'medium',
    timeStyle: 'short',
  }).format(date);
}

// "2026-05-28T14:30:00Z" → "May 28, 2026, 10:30 AM" (in US Eastern)

Keep your frontend state in UTC. Convert only inside the render function or a computed property. This ensures that any time comparisons, sorting, or arithmetic on the client use a consistent timeline.

Handling User Intent

The UTC everywhere pattern handles "when did this happen?" perfectly. But "when should this happen?" requires extra care when the user's intent is tied to a local time.

Consider a daily report scheduled for "9:00 AM in New York." If you store 14:00:00Z (9 AM EDT), the report will drift to 8:00 AM EST when DST ends in November — because UTC does not change, but the offset does.

The correct approach: store the local time and timezone, not the pre-computed UTC:

# Model
class ScheduledReport:
    local_time: time          # 09:00
    timezone: str             # "America/New_York"
    days: list[str]           # ["mon", "tue", "wed", "thu", "fri"]

# Compute next run in UTC at execution time
def next_run_utc(report: ScheduledReport) -> datetime:
    from zoneinfo import ZoneInfo
    tz = ZoneInfo(report.timezone)
    now = datetime.now(tz)
    target = now.replace(hour=report.local_time.hour,
                         minute=report.local_time.minute,
                         second=0, microsecond=0)
    if target <= now:
        target += timedelta(days=1)
    return target.astimezone(timezone.utc)

This pattern — store intent in local time, compute execution in UTC — correctly handles DST transitions.

Key Takeaways

  • Store, compute, and transmit timestamps in UTC across every layer of your application.
  • Use TIMESTAMPTZ in PostgreSQL and TIMESTAMP in MySQL; avoid timezone-less types.
  • API contracts should require Z or explicit offset in all JSON date-time strings.
  • Convert to local time only at the display layer using Intl.DateTimeFormat or equivalent.
  • For scheduled events tied to local time, store the local time and timezone, then compute UTC at execution time to handle DST correctly.

Try It Yourself

Converting between UTC and local timezones? The Timezone Converter lets you compare times across any two timezones, and the Timestamp Converter handles Unix-to-ISO conversions with full UTC support.