Why are your log times always 8 hours off?

A timestamp has no time zone of its own. The 8-hour gap almost always appears when the number is turned into a readable time.

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 trailing Z means 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: TIMESTAMP converts the value to UTC using the session time zone on write and converts it back on read. DATETIME stores exactly what you gave it. Pick the wrong column type and a client in another zone reads a different value.
  • PostgreSQL: timestamptz stores UTC while preserving zone semantics; timestamp stores no zone at all. The documentation recommends the former by default.
  • Java: Instant is an absolute point; LocalDateTime carries no zone. Serialise a LocalDateTime straight to a string and the zone information is gone.
  • Python: datetime comes in naive (no zone) and aware (zone attached) flavours, and subtracting one from the other raises an exception. Attaching a zone via zoneinfo is the modern default.
  • JavaScript: a Date is always a millisecond stamp internally, but getHours() and toString() render in the host zone, while toISOString() 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.

Related tools