The UTC Everywhere Pattern
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:
| Layer | What to Do | What to Avoid |
|---|---|---|
| Database | Store timestamps as TIMESTAMPTZ (UTC internally) | TIMESTAMP without timezone |
| Backend | Accept and return ISO 8601 with Z suffix | Parsing timestamps without offsets |
| API | Use 2026-05-28T14:30:00Z | Passing local-time strings |
| Frontend | Convert UTC to local only for rendering | Storing local timestamps in state |
| Logs | Write timestamps in UTC | Logging 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
TIMESTAMPTZin PostgreSQL andTIMESTAMPin MySQL; avoid timezone-less types. - API contracts should require
Zor explicit offset in all JSON date-time strings. - Convert to local time only at the display layer using
Intl.DateTimeFormator 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.