Unix Timestamp Converter: Why My Reminder Emails Fired 8 Hours Late (Python, Node, and the Browser)
Last month I shipped a small reminder feature: users pick a date and time, and a scheduler fires an email at that exact moment. Every reminder went out at 5:00 PM Beijing time instead of 9:00 AM. Eight hours late, every single time, no exceptions.
The user had picked "2026-11-20 09:00" in Shanghai. The database stored 1795165200. The correct value for 2026-11-20 09:00 Asia/Shanghai is 1795136400 — the gap is exactly 28,800 seconds. Somewhere between the form and the database, my code read the wall clock as if it were UTC.
Here is the annoying part: my tests passed. My laptop runs Asia/Shanghai, production runs TZ=UTC, and the bug only existed on the second machine. I'll walk through the fix in Python, the equivalent in Node, and how I check timestamps before they touch a database now.
Method 1: Python 3.11 with ZoneInfo
The original mistake was a naive datetime plus .timestamp():
# Python 3.11.1 — the buggy version from datetime import datetime dt = datetime(2026, 11, 20, 9, 0, 0) print(int(dt.timestamp())) # on my TZ=Asia/Shanghai laptop → 1795136400 (right, by accident) # on the TZ=UTC server → 1795165200 (wrong, +8h) A naive datetime carries no timezone, so .timestamp() falls back to whatever the process locale says. On a UTC server, 09:00 means 09:00 UTC. On my laptop it meant 09:00 +08:00. Same code, two answers, both "correct" per the docs.
The fix is to attach the zone explicitly:
# Python 3.11.1 — explicit zone from datetime import datetime, timezone from zoneinfo import ZoneInfo dt = datetime(2026, 11, 20, 9, 0, 0, tzinfo=ZoneInfo("Asia/Shanghai")) print(int(dt.timestamp())) # 1795136400 — everywhere, on every machine print(dt.astimezone(timezone.utc).isoformat()) # 2026-11-20T01:00:00+00:00 ZoneInfo is in the standard library since Python 3.9; on Debian and Ubuntu the zone data comes from the system tzdata package, so there is nothing to pip install. The official zoneinfo documentation covers the data-source details. If your input is already a string with an offset, datetime.fromisoformat("2026-11-20T09:00:00+08:00").timestamp() gives the same 1795136400 with even less ceremony.
Method 2: Node 22 with explicit offsets
JavaScript has its own version of this trap. The space-separated format is not ISO 8601, so each engine gets to decide what it means:
// Node 22.13.0 — verified on both machines new Date("2026-11-20 09:00:00").getTime() // TZ=Asia/Shanghai → 1795136400000 // TZ=UTC server → 1795165200000 (the same 8-hour bug) V8 parses that string as local time. On the user's phone that is what you want; on a UTC server it is exactly what you don't. I stopped relying on it. Two options that behave identically everywhere:
// Option A: spell the offset out — no dependencies Date.parse("2026-11-20T09:00:00+08:00"); // 1795136400000 // Option B: luxon, when the zone comes from a user profile import { DateTime } from "luxon"; // luxon 3.5.0 DateTime.fromObject( { year: 2026, month: 11, day: 20, hour: 9 }, { zone: "Asia/Shanghai" } ).toUnixInteger(); // 1795136400 Option A works because the offset rides along in the string itself. Option B is worth the dependency once you need named zones, DST math, or formatting in the user's zone. The MDN Date.parse page documents which forms are standardized and which are left to the implementation — worth reading once, it explains a lot of "works on my machine" reports.
Method 3: verify in the browser before shipping
Most of these bugs now get caught before they reach me. When support forwards a screenshot with a stored number like 1795165200, I paste it into a browser-based Unix timestamp converter and read the two lines it shows: UTC says 2026-11-20 01:00, and the local line says 09:00 with a +08:00 offset. If the UTC line instead reads 09:00, I know it's this exact bug — someone converted a Shanghai wall clock as if it were UTC.
Zero setup, no container, and it runs from the customer's own machine, which matters when you suspect their environment. For the reverse direction I type the intended local time into this timestamp converter tool, copy the number it produces, and compare it with what the backend computed. Any difference is measured in exactly 3,600-second steps, which tells you which timezone layer got dropped.
Three traps that produced the wrong number
Trap 1: time.mktime inherits the server's zone too. The struct-tuple API looks old-school safe. It is local-time all the way down:
import time time.mktime((2026, 11, 20, 9, 0, 0, 0, 0, -1)) # TZ=Asia/Shanghai → 1795136400.0 # TZ=UTC → 1795165200.0 If you genuinely have UTC wall-clock values, the stdlib answer is calendar.timegm(). But note the symmetry: timegm on Shanghai wall-clock values returns 1795165200 — the same wrong number as mktime on the UTC box. I once "fixed" a mktime complaint with timegm and made the data worse.
Trap 2: the two date-only forms in JavaScript disagree. A string with only a date parses as UTC midnight; the constructor treats the numbers as local:
new Date("2026-11-20").getTime(); // 1795132800000 — UTC midnight new Date(2026, 10, 20).getTime(); // 1795104000000 on a +08:00 machine // gap: 28800000 ms — but on a TZ=UTC server the gap is 0 I compared a config value from the first form with a user value from the second. The unit tests ran in UTC CI where both agree, so the 8-hour mismatch only surfaced for the first real user in Shanghai. The fix: pick one form and convert through it consistently.
Trap 3: trusting "it worked locally" as a timezone test. The most expensive hour of this whole incident went to a wrong conclusion — I assumed the queue was delayed, because my local repro showed correct timestamps. Run timezone-sensitive code with TZ=UTC deliberately (in the test command itself, not just prod) and the local/prod gap closes:
TZ=UTC python3 repro.py # 1795165200 — reproduces it TZ=UTC node repro.js # 1795165200000 — same Wrap-up
| Conversion | Correct result | Wrong result | Root cause |
|---|---|---|---|
| Shanghai 09:00 → epoch | 1795136400 | 1795165200 | naive datetime / space-format string on a TZ=UTC host |
time.mktime struct | local-time aware | +8h on UTC hosts | mktime always reads local time |
| Date-only string vs constructor | agree on UTC hosts | 28,800,000 ms apart elsewhere | string = UTC, constructor = local |
One rule has survived every incident since: store the epoch number, attach the zone at the edges where humans type, and when a stored number looks suspicious, check it against the converter on utilvy before debugging the scheduler. The scheduler was never broken.
More free browser-based utilities at utilvy.com.

