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
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)
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"}
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
Zfor UTC, not+00:00. A few JSON parsers treat the former specially and the latter as a generic offset, and JavaScript'sDate.parsehas historically been inconsistent with offsets. - For JavaScript, don't hand-roll date parsing. Use
new Date("2026-08-19T07:30:00Z")(ISO withZis 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
);
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
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
- Every datetime column is
timestamptz(or the equivalent in your stack), or a deliberate wall-clock + timezone pair. - No naive datetime anywhere in app code —
datetime.now(timezone.utc), neverutcnow(). - One wire format: ISO 8601 with
Zfor UTC. - Recurring events store intent (time + IANA zone) separately from instants.
- 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)