Start with the conclusion: a Unix timestamp carries no time zone. It is simply the number of seconds elapsed since 1970-01-01 00:00:00 UTC. The "8 hours off" you see almost always appears at the step where the number becomes a human-readable string.
What a timestamp actually is
A Unix timestamp is an integer representing one instant. It has no notion of "Beijing time" or "New York time" — the same instant yields the same number everywhere. That is exactly why timestamps are the safest thing to store in a database: moving servers or changing the system time zone cannot distort them.
The only thing to watch is precision: 10 digits means seconds, 13 digits means milliseconds. Read a millisecond value as seconds and you land somewhere in the year 50000; read a second value as milliseconds and you fall back to January 1970. This mistake shows up in every language.
Three sources of the 8-hour gap
1. Storage and display use different conventions. The back end stores UTC, the front end formats it as local time — both are correct; nobody said which zone was meant.
2. The server is not on UTC. Some images default to Asia/Shanghai, others to UTC. The same code writes log lines 8 hours apart on two machines. When log times do not line up, check date and timedatectl first.
3. Strings get an implicit time zone when parsed. Something like 2026-10-19 00:00:00 without an offset is read as local time by browsers and Node, but as UTC by some libraries. One string, two readings, 8 hours apart.
A quick self-check
When unsure, do two things. First, read the same timestamp as UTC and as local time — if the results differ by a whole number of hours, it is a zone issue. Second, convert the time you expected into a timestamp and compare it with the value in your logs. A difference of 28,800 seconds is exactly 8 hours.
Three habits that remove most of these bugs
- Store UTC timestamps, or ISO 8601 with an offset (e.g.
2026-10-19T02:00:00Z) - Specify the time zone explicitly when displaying; never rely on the runtime default
- In APIs, always include an offset or state clearly that the value is UTC
In one sentence
Store UTC, display in the user's zone, parse with an explicit offset. Those three rules prevent most "the time is wrong" reports.
One instant, five notations
Write the same moment — 2026-10-19 02:00:00 UTC — five different ways and the source of the confusion becomes obvious:
1792375200— Unix seconds. Absolute, unambiguous.1792375200000— Unix milliseconds. Absolute, unambiguous.2026-10-19T02:00:00Z— ISO 8601, where the trailingZmeans UTC. Absolute.2026-10-19T10:00:00+08:00— ISO 8601 with an explicit offset. Absolute.2026-10-19 10:00:00— no zone at all. Not an instant: it means whatever local zone the parser happens to run in.
The first four point at the same place on the timeline. The fifth points somewhere different on every machine that reads it. Almost every "the times don't match" argument comes from treating the fifth form as if it were one of the first four.
Defaults differ across stacks
This is where things break. The same idea — "store a time" — is handled differently by every layer:
- MySQL:
TIMESTAMPconverts the value to UTC using the session time zone on write and converts it back on read.DATETIMEstores exactly what you gave it. Pick the wrong column type and a client in another zone reads a different value. - PostgreSQL:
timestamptzstores UTC while preserving zone semantics;timestampstores no zone at all. The documentation recommends the former by default. - Java:
Instantis an absolute point;LocalDateTimecarries no zone. Serialise aLocalDateTimestraight to a string and the zone information is gone. - Python:
datetimecomes in naive (no zone) and aware (zone attached) flavours, and subtracting one from the other raises an exception. Attaching a zone viazoneinfois the modern default. - JavaScript: a
Dateis always a millisecond stamp internally, butgetHours()andtoString()render in the host zone, whiletoISOString()always renders in UTC.
One rule covers all of them: any type called "local time" carries no zone information, so it distorts as soon as it crosses a machine boundary.
Never use zone abbreviations — use IANA names
CST alone can mean China Standard Time (UTC+8), US Central Standard Time (UTC−6) or Cuba Standard Time (UTC−5). EST is no safer: it ignores daylight saving, while the US east coast actually runs on EDT (UTC−4) for half the year.
Use IANA identifiers instead: Asia/Shanghai, America/New_York, Europe/Berlin. They encode the full daylight-saving history, are maintained in the system tz database, and stay correct across arbitrary years.
One easily forgotten detail: China observed daylight saving from 1986 to 1991, so summer timestamps from those years sit at UTC+9. Asia/Shanghai handles that correctly; a hard-coded UTC+8 will be an hour off.
Three questions before you read any code
- Is this value an instant or a wall-clock reading? If it is a numeric stamp, the bug must be in formatting. If it is a string, first establish which zone it was written in.
- Do the producer, the store and the browser agree on a zone? If not, a conversion exists somewhere — and that conversion is the suspect.
- Is the delta a whole number of hours? Eight hours, or 5.5 hours (India, UTC+5:30), is a zone problem. A few seconds' drift is more likely clock sync or precision, and has nothing to do with zones.
These three questions eliminate half the possibilities before you open a single log file.