At twenty past one in the morning, a group chat woke me up: someone in operations posted a dashboard screenshot where the entire Created At column for March orders had turned into 1970-01-21 00:13:20. The frontend developer on call got up and read the code, and found it within five minutes: the API returned create_time as a 10-digit seconds value, and his new Date(create_time) treated that number as milliseconds. The same 1700000000 lands in November 2023 when it is read as seconds and falls back to January 1970 when it is read as milliseconds. After that, our team wrote the timestamp conventions onto one card, pinned it to the top of the wiki, and new hires picking up a debugging task rarely need to ask anyone.
1. Digit count is the unit: learn to read it at a glance
A timestamp is just an integer, and what it means depends entirely on the unit it was produced in. My first check is always to count the digits rather than guess which language the backend uses:
| Digits | Unit | Typical source | Relation to seconds |
|---|---|---|---|
| 10 digits | Seconds | PHP time(), Go Unix(), Linux date +%s | Baseline |
| 13 digits | Milliseconds | JavaScript Date.now(), Java currentTimeMillis() | Seconds x 1000 |
| 16 digits | Microseconds | Go UnixMicro(), Python time_ns()//1000 | Seconds x 1000000 |
| 19 digits | Nanoseconds | Python time_ns(), Go UnixNano() | Seconds x 10⁹ |
One more range check: between 2020-01-01 and 2030-01-01, seconds values fall between 1577836800 and 1893456000, and milliseconds between 1577836800000 and 1893456000000. Parse a 13-digit value as 10 digits and the date flies off to somewhere around the year 50000; read 10 digits as 13 and you drop back to 1970. Nearly every time display problem I have run into sits in one of those two buckets.
2. The missing 8 hours are not in the timestamp, they are in the layer that reads it
A timestamp is an absolute quantity counted from 1970-01-01 00:00:00 UTC and carries no time zone information at all. The same number is the same value in London and in Shanghai; the disagreement only appears at the step where the integer is translated into a date a person can read. Take 1700000000: its UTC time is 2023-11-14 22:13:20, and in Beijing time that is 2023-11-15 06:13:20, exactly 8 hours apart.
Four places in real projects account for most of the 8-hour illusions, and each is worth ruling out in turn:
- The container defaults to UTC while the host runs Asia/Shanghai, so log wall-clock times sit 8 hours away from what the business sees. Run
TZ=Asia/Shanghai date -d @1700000000once with and once withoutTZand you can tell which layer is wrong. - MySQL
FROM_UNIXTIME()outputs in the session time zone. RunSELECT @@session.time_zonefirst, then cross-check in both directions againstUNIX_TIMESTAMP(); the column itself is usually fine. - PHP without
date_default_timezonefalls back to UTC, so the output ofdate()is shifted as a whole. - On the frontend,
new Date(ts).toISOString()always returns UTC with a trailing Z, whiletoLocaleString()renders in the system time zone. Data that looks inconsistent between two functions is the most common source of self-doubt while debugging.
3. The order I debug in, and where the tools come in
- Count the digits to fix the unit first, and write down the conclusion, seconds or milliseconds, so two people do not work from two different readings.
- Then fix the baseline: we compressed our rule into one sentence. The database stores only 10-digit UTC seconds, API field names carry the unit in a suffix (
create_time_ms), and the presentation layer converts to local time. - Verify in both directions: take a known moment (say 23:07 last night), convert it to a timestamp, convert it back, and see whether you land on the same value. This single step exposes both unit and time zone errors.
- For bulk checks I paste the suspicious numbers into Timestamp Converter. It works out seconds versus milliseconds on its own and shows Beijing time, UTC, ISO 8601 and relative descriptions such as 3 days ago, which saves me doing time zone offsets in my head. Going the other way, date to timestamp, happens on the same page, and it also carries the timestamp snippet for several languages, so switching languages is a matter of copying that block.
- To measure the gap between two moments, or to work out which date is 90 days from now, I use Time Calculator. It returns the weekday and the timestamp along with the result, which is more reliable than dividing 86400 by hand.
4. Edge cases that are easy to skip
These are the ones I only check after being burned, written down here as a list:
| Edge case | What it looks like | What to do |
|---|---|---|
| 32-bit overflow | Signed 32-bit seconds go negative at 2038-01-19 03:14:07 UTC; the MySQL TIMESTAMP column caps at the same moment | Move the column to BIGINT, and check the headroom with SELECT MAX() before migrating |
| Negative timestamps | Times before 1970 are negative, and some database functions and charting libraries show them blank or throw an error | Store historical archives as date strings and keep timestamps for recent decades only |
| Leap seconds | Timestamps do not express leap seconds, so a scheduler aligned to UTC seconds can see a repeated second on a leap day | Use a monotonic clock (CLOCK_MONOTONIC) for critical timing, and do not count down with wall time |
| Daylight saving time | US clocks jump from 02:00 to 03:00 on 2024-03-10, so that hour does not exist locally; the switch back on 11-03 repeats an hour | Store IANA zone names such as America/New_York, not a fixed offset like UTC+4 |
| Same-second collisions | Using a timestamp as a filename or cache key means requests within the same second overwrite each other | Append a random suffix at millisecond resolution, or use a UUID |
Scheduling meetings across countries is a different need: what you actually want is usually not a timestamp but whether it is working hours for the other side right now. For that I first check the current time and offset for both cities in World Clock, and then convert the chosen moment into a timestamp for the calendar API.
5. Common questions
Q: The conversion is off by 8 hours. Is the timestamp wrong?
Almost never. Check the time zone used for parsing, then the digit count. The timestamp value has not changed; only the layer that renders it has.
Q: In JavaScript, should I multiply by 1000 or divide by 1000?
Look at the input digits. JavaScript Date only understands milliseconds, so a 10-digit value needs multiplying by 1000; going the other way, if you send Date.now() to an endpoint that parses seconds, divide by 1000 and round. The most robust fix is to put the unit in the field name.
Q: How do I tell quickly whether a messy batch of log times is in seconds or milliseconds?
Take the largest value and count its digits, then compare against the range: between 2020 and 2030, 10 digits is seconds and 13 is milliseconds. Mixing both in one log is not rare, so bucket them first and convert each group.
Q: Is the 2038 problem far away?
Not while 32-bit integer columns are still running on old systems. The cost is mostly changing column types and rebuilding indexes, it gets worse the longer you wait, and the best moment is the next table restructuring you were going to do anyway.
Looking back at that incident, the real fix was not that one new Date() line but the rule that whoever produces the number labels the unit, and whoever displays the time owns the time zone, finally landing in the API documentation. These days, when I see 1970 or the year 50000, I count the digits first and check the time zone second; with those two steps in order, most time confusion never reaches the second one.