DEV Community

Sir Max
Sir Max

Posted on

Time Zones in APIs: 5 Rules That Would Have Saved Me 3 Days of Debugging

A few years ago I shipped a booking feature that passed every test I ran locally. Then a user in Berlin wrote in: "My booking is showing the wrong time." Then one in Sydney. Then one in São Paulo.

Three days later I found the bug. I had been storing local server time as a naive datetime, and doing date arithmetic in Python where the server happened to run at UTC+0 — until a container rebuild picked up a different default timezone and everything silently shifted by eight hours.

Nothing crashed. No error was thrown. The data was just wrong, everywhere, retroactively.

Here are the five rules I now follow on every project. They would have saved me that weekend.

Rule 1: Store everything in UTC

Your database should hold instants in time, not wall-clock readings. An instant is "2026-08-19T07:30:00Z". A wall-clock reading is "7:30 AM" — which means a different instant in New York than it does in London.

In PostgreSQL, this is the difference between timestamptz and timestamp:

-- Good: timestamptz stores an absolute instant, stored internally as UTC
CREATE TABLE bookings (
  id           bigint PRIMARY KEY,
  starts_at    timestamptz NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

-- Bad: timestamp stores whatever you give it, no zone context
-- "2026-08-19 07:30:00" is ambiguous and will bite you later
Enter fullscreen mode Exit fullscreen mode

timestamptz doesn't actually store a timezone — it normalizes everything to UTC and remembers the instant. The timezone you see is applied at display time. That's exactly what you want.

The single exception: if the user's intent is "9:00 AM every day, local to them" (an alarm, a recurring meeting), store the wall-clock time and the timezone identifier as separate columns. More on that in Rule 4.

Rule 2: Never use naive datetimes in application code

Python lets you shoot yourself in the foot with surprising politeness. datetime.utcnow() sounds like it gives you UTC — and it does, numerically — but it returns a naive object with no tzinfo attached. The moment you compare it against an aware datetime, you get a TypeError, or worse, silent wrongness in a library that assumes a zone.

from datetime import datetime, timezone

# Bad: naive, no timezone attached
now = datetime.utcnow()          # deprecated in 3.12 for exactly this reason

# Good: explicitly aware
now = datetime.now(timezone.utc)

# Convert a stored instant into a user's local time
from zoneinfo import ZoneInfo
berlin = ZoneInfo("Europe/Berlin")
local_time = now.astimezone(berlin)
Enter fullscreen mode Exit fullscreen mode

Use zoneinfo (stdlib since Python 3.9) and pass IANA identifiers like "Europe/Berlin" or "America/Sao_Paulo". Never store or hardcode offsets like +02:00, because offsets change — Berlin is +01:00 in winter and +02:00 in summer. An offset is a snapshot; a timezone is a rulebook.

Rule 3: Use ISO 8601 on the wire

Pick one wire format and enforce it everywhere. ISO 8601 with an explicit offset (or a Z for UTC) is unambiguous and parseable by every language you'll touch.

# Serialize an instant correctly
import json
from datetime import datetime, timezone

def to_json(dt: datetime) -> str:
    return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")

now = datetime.now(timezone.utc)
payload = json.dumps({"created_at": to_json(now)})
# {"created_at": "2026-08-19T07:30:00.123456Z"}
Enter fullscreen mode Exit fullscreen mode

The worst format you can choose is one that looks fine but drops the offset — "2026-08-19 07:30:00". Every consumer will guess a timezone, and they'll guess differently. I've watched two services written in different languages disagree about "now" by a full day because one assumed UTC and the other assumed server-local.

Two practical notes:

  • Use Z for UTC, not +00:00. A few JSON parsers treat the former specially and the latter as a generic offset, and JavaScript's Date.parse has historically been inconsistent with offsets.
  • For JavaScript, don't hand-roll date parsing. Use new Date("2026-08-19T07:30:00Z") (ISO with Z is safe) and avoid "2026/08/19" or any locale-specific string, which varies by browser.

Rule 4: Separate "instant" from "intent"

This is the rule that separates people who've been burned from people who haven't.

Most timestamps are instants: when a record was created, when a payment went through. Store those as UTC (timestamptz), display them in the viewer's local zone.

But some timestamps are intents: a user wants their report generated "every Monday at 9 AM", or a meeting "at 3 PM my time". If you naively convert "3 PM in New York" to UTC and store only that, the meeting will drift by an hour every time daylight saving starts or ends.

Store both:

CREATE TABLE recurring_reports (
  id            bigint PRIMARY KEY,
  schedule_time time NOT NULL,          -- 09:00:00, wall-clock intent
  timezone      text NOT NULL,          -- 'America/New_York', IANA name
  last_run_at   timestamptz             -- the last actual instant it ran
);
Enter fullscreen mode Exit fullscreen mode

schedule_time + timezone captures what the user meant. last_run_at captures what actually happened. Mixing the two is where recurring-event bugs live.

Rule 5: Test around DST transitions

If your test suite only ever runs in one timezone and never crosses a daylight-saving boundary, you are testing the one scenario that's guaranteed not to break.

import pytest
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

def localize(dt_utc: datetime, tz_name: str) -> datetime:
    return dt_utc.astimezone(ZoneInfo(tz_name))

def test_dst_spring_forward():
    # 2026-03-29 01:30 UTC in Berlin -> 03:30 (clocks jumped from 02:00 to 03:00)
    instant = datetime(2026, 3, 29, 1, 30, tzinfo=timezone.utc)
    assert localize(instant, "Europe/Berlin").hour == 3

def test_dst_fall_back():
    # 2026-10-25 01:30 UTC in Berlin -> 02:30 (clocks fell back)
    instant = datetime(2026, 10, 25, 1, 30, tzinfo=timezone.utc)
    assert localize(instant, "Europe/Berlin").hour == 2
Enter fullscreen mode Exit fullscreen mode

Add a couple of these to your CI matrix — run the whole suite once with TZ=America/Sao_Paulo and once with TZ=Pacific/Auckland — and you'll catch most timezone bugs before a customer in another hemisphere does.

The checklist I run before shipping anything that touches time

  1. Every datetime column is timestamptz (or the equivalent in your stack), or a deliberate wall-clock + timezone pair.
  2. No naive datetime anywhere in app code — datetime.now(timezone.utc), never utcnow().
  3. One wire format: ISO 8601 with Z for UTC.
  4. Recurring events store intent (time + IANA zone) separately from instants.
  5. CI runs the test suite under at least two timezones, including a DST boundary.

None of this is exotic. It's the kind of boring discipline that feels like overkill right up until the weekend you lose to a bug that never throws an error. Learn it once, from my mistake, instead of from your own.

Top comments (0)