Store UTC, show local: timestamps that survive zones
A post set for 22:00 went out at midnight. The rule and copy-paste code for PostgreSQL, Python and JavaScript.
The fix: PostgreSQL
Use timestamptz. PostgreSQL stores it as UTC and converts to the session TimeZone only on output. It does not keep the zone you typed, so save the user's zone name (like Europe/Warsaw) in its own column if you need it. AT TIME ZONE turns a local wall time into the instant, and back.
CREATE TABLE posts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
publish_at timestamptz NOT NULL
);
INSERT INTO posts (publish_at) VALUES (
TIMESTAMP '2026-10-25 22:00' AT TIME ZONE 'Europe/Warsaw');
SELECT publish_at AT TIME ZONE 'America/New_York' FROM posts;The fix: Python
Use aware datetimes. Convert the user's local time to UTC for storage, convert back to the viewer's zone for display. The UTC constant needs Python 3.11 or newer.
from datetime import datetime, UTC
from zoneinfo import ZoneInfo
warsaw = ZoneInfo("Europe/Warsaw")
local = datetime(2026, 10, 25, 22, 0, tzinfo=warsaw)
print(local.astimezone(UTC).isoformat())
# 2026-10-25T21:00:00+00:00The fix: JavaScript
toISOString() always returns UTC with a Z. Intl.DateTimeFormat shows it in any IANA zone. Leave timeZone out to use the viewer's own zone.
const when = new Date("2026-10-25T21:00:00Z");
when.toISOString(); // "2026-10-25T21:00:00.000Z"
new Intl.DateTimeFormat("en-GB", {
timeZone: "America/New_York",
hour: "2-digit", minute: "2-digit",
timeZoneName: "short",
}).format(when); // "17:00 GMT-4"Check it worked
Run the same line with the machine set to two zones. A time written without Z or an offset gives a different instant in each. That is the bug. A string that ends in Z gives the same instant everywhere.
for tz in UTC Europe/Warsaw; do
TZ=$tz node -p 'new Date("2026-10-25T22:00:00").toISOString()'
done
# 2026-10-25T22:00:00.000Z
# 2026-10-25T21:00:00.000Z <- same text, other instantPro tip: the hour that happens twice
When clocks go back, one local hour repeats. Python's zoneinfo uses fold=0 for the first pass and fold=1 for the second. Adding a day to a local datetime keeps the wall-clock time, so the real gap can be 23 or 25 hours. Storing UTC avoids both. You only meet them when you read local times typed by people.
from datetime import datetime
from zoneinfo import ZoneInfo
w = ZoneInfo("Europe/Warsaw")
# clocks go back at 03:00 that night
t = datetime(2026, 10, 25, 2, 30, tzinfo=w)
print(t.utcoffset(), t.replace(fold=1).utcoffset())
# 2:00:00 1:00:00Common mistake
Naive datetimes. datetime.utcnow() returns a datetime with no zone and is deprecated since Python 3.12; use datetime.now(UTC). In JavaScript, "2026-10-25T22:00:00" is read as local time, while a date-only string is read as UTC. Always write the Z or an offset. On Windows, ZoneInfo raises ZoneInfoNotFoundError until you run: pip install tzdata
Official docs
- www.postgresql.org/docs/current/datatype-datetime.html
- www.postgresql.org/docs/current/functions-datetime.html
- docs.python.org/3/library/zoneinfo.html
- docs.python.org/3/library/datetime.html
- developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
- developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
- developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
One fix of twenty: the whole Claude Code survival kit.
Follow @whyshouldaicareA new disaster and its fix every day. Comment the word under a reel to get its guide.