DevKitLab Logo DevKitLab
Timestamps / Time Zones / JavaScript / Debugging

Why Is My Unix Timestamp Wrong? Seconds, Milliseconds, and Time Zones

A timestamp that shows the wrong time is almost never a broken number — it's a category error. A Unix timestamp isn't a date and has no time zone; it's a single count of seconds from one fixed instant. Once you separate the instant from the way it's rendered, 'off by decades', 'off by three hours', and 'off by a day' each collapse into one specific, findable cause.

You log a timestamp and it comes out wrong. Sometimes spectacularly wrong — a record you created this morning claims it happened in January 1970, or in the year 55,840. Sometimes quietly wrong — the time is right but three hours off, or a date lands on the 14th when you meant the 15th. So you start patching: multiply by something, subtract a few hours, wrap it in another conversion. Each fix works on the one example in front of you and breaks the next, because you’re treating symptoms without the one fact that explains all of them at once.

Here’s that fact: a Unix timestamp is not a date, and it has no time zone. It’s a single integer — the number of seconds elapsed since one fixed instant, 1970-01-01T00:00:00Z — and that same integer names the same moment everywhere on Earth. It doesn’t know what year it is, what calendar you use, or whether you’re in Tokyo or Chicago. All of that meaning is added later, when you render the number into something a human reads. Almost every “wrong timestamp” bug is not a wrong number at all. It’s a category error: mixing up the instant with the way it’s displayed, or feeding a value into a function that expected a different unit.

This article separates those two layers and keeps them separate. First, what a timestamp actually is — one number, no zone. Then the handful of places the two layers get crossed: the seconds-versus-milliseconds swap that throws you into 1970, the time-zone confusion that shifts a correct instant by a few hours, the string-parsing rule that moves a date by a whole day, and the calendar-math traps around daylight saving. By the end you’ll have a short checklist that isolates almost any timestamp bug to one specific cause.

The one idea: an instant is a number; a date is a rendering of it

There are two layers, and nearly every bug lives in the gap between them.

  1. The instant. An absolute point on the universe’s timeline. In computing it’s usually a Unix timestamp — one integer, no time zone, the same value for everyone. 1700000000 is a specific moment; it is that moment in São Paulo and in Seoul simultaneously.
  2. The rendering. A human-readable date-and-time — "2023-11-14 22:13:20", or "Nov 14, 5:13 PM" — which only means something once you also say which time zone it’s expressed in. The same instant renders as a different wall-clock string in every zone.

The instant is the truth you store, transmit, and compare. The rendering is a view of it, produced on demand for a human, in a particular zone, in a particular locale. Keep those two words — instant and rendering — in mind for the rest of this article, because every trap below is some version of the same mistake: treating a rendering as if it were the instant, or converting between the two while getting the unit or the zone wrong.

What a Unix timestamp actually is

Strip away the tooling and a Unix timestamp is almost embarrassingly simple: it counts the seconds that have passed since 1970-01-01T00:00:00Z, an arbitrary origin called the Unix epoch. A value of 0 is that midnight in 1970; 1700000000 is roughly 53.9 years later; times before 1970 are simply negative.

Two things people quietly get wrong about it:

  • It is not “in UTC” the way a rendered date is. The number itself carries no zone. UTC appears only in two places: as the label for the epoch (the count starts at midnight UTC, 1970), and as the neutral zone you’ll usually render into. The integer 1700000000 is not a UTC time and not a local time — it’s an instant, and “UTC” is just the most convenient dialect for reading it aloud.
  • It ignores leap seconds. Real UTC has had more than two dozen leap seconds inserted since 1972 to keep clocks aligned with the Earth’s slightly irregular rotation. Unix time pretends they never happened: it treats every day as exactly 86,400 seconds. That makes the arithmetic clean and reversible — you can convert a timestamp to a date and back with plain division — at the cost of the count not being a perfectly true tally of physical seconds. For everyday work this never matters; it’s worth knowing only so the phrase “seconds since the epoch” doesn’t mislead you into expecting astronomical precision.

That simplicity is the whole appeal. Comparing two instants is just comparing two integers. Sorting events is sorting numbers. There’s no calendar, no zone, no ambiguity — until you convert to or from a human date, which is exactly where the bugs start.

The number-one bug: seconds versus milliseconds

This is the single most common way a timestamp goes wrong, and it’s worth its own section because the failure is so dramatic. There are two competing conventions for “the count since 1970”:

  • Unix / POSIX convention: seconds. date +%s on the command line, the exp and iat claims in a JWT, most database epoch functions, most Unix system calls — all seconds.
  • JavaScript convention: milliseconds. Date.now() and new Date().getTime() return milliseconds since the epoch, a thousand times larger.

Mix the two and you’re off by a factor of 1000 — which, on the timeline, is a factor of decades. The classic version, in JavaScript:

// A Unix timestamp in SECONDS, e.g. from an API or a JWT's exp claim:
const ts = 1700000000;

new Date(ts);        // Tue Jan 20 1970 — the Date constructor wants MILLISECONDS
new Date(ts * 1000); // Tue Nov 14 2023 — correct

new Date(1700000000) doesn’t error. It cheerfully interprets your seconds as milliseconds, lands 1.7 billion milliseconds (about 20 days) past the epoch, and hands you a date in January 1970. Going the other way, if you need seconds from JavaScript’s milliseconds, you must divide and floor:

Math.floor(Date.now() / 1000); // Unix timestamp in seconds

The tell, for the current era, is digit count: a seconds timestamp is 10 digits (1700000000), and a milliseconds timestamp is 13 (1700000000000). If a value near 1.7 billion produced a date in 1970, you handed seconds to something that wanted milliseconds; if a value near 1.7 trillion produced a date tens of thousands of years out, you did the reverse. When you’re not sure which unit you’re holding, paste the raw number into a Unix timestamp converter — it reads the value both ways and shows you which one lands on a plausible date, so you resolve the unit by looking instead of guessing.

The timestamp has no time zone — the confusion is in the formatting

Now the quieter failure: the date is nearly right but off by a few hours. Here the instinct to “fix the number” is exactly wrong, because the number is fine. You’re rendering a correct instant in the wrong time zone.

Recall that the instant carries no zone. When you turn it into text, you — or your language’s default — choose a zone, and the same instant produces different wall-clock strings:

const d = new Date(1700000000 * 1000);

d.toISOString();   // "2023-11-14T22:13:20.000Z"  — always UTC, marked by the trailing Z
d.toString();      // "Tue Nov 14 2023 17:13:20 GMT-0500 ..." — the runtime's local zone
d.toLocaleString(); // a locale- and zone-specific rendering

Every line above describes the same instant. 22:13:20Z and 17:13:20 five hours behind it are not two different times — they’re one moment named in two zones. So when a log entry reads “three hours earlier than it should,” the usual cause isn’t a corrupted timestamp; it’s that the value was formatted in UTC where you expected local time, or in the server’s zone where you expected the user’s. The fix is to control the zone at the point of display, not to add or subtract hours from the stored value — bake an offset into the number and you’ve corrupted the one thing that was correct.

This is also the rule for storing and transmitting time: keep the instant zone-neutral — a Unix timestamp, or an ISO 8601 string with an explicit offset like 2023-11-14T22:13:20Z — and apply a zone only at the very edge, when a human needs to read it. (Which of those two notations — an integer timestamp or an ISO string — to actually reach for in a database is its own article on the storage trade-off.) A time written down as bare local wall-clock text with no offset (“2023-11-14 17:13:20”) has thrown away the information needed to know which instant it was. To see one instant spread across the zones your users actually live in, a time zone converter lays them side by side, which also makes an “off by hours” bug obvious: the moment is consistent, only the labels differ.

The parsing footgun: which zone did the string mean?

Turning a string back into an instant has its own sharp edge, and it’s responsible for a huge share of “the date is one day early” bugs. In JavaScript — and this behavior is specified, not a quirk of one engine — the zone a date string is assumed to be in depends on whether it includes a time:

new Date("2026-07-15");            // date-only → parsed as UTC midnight
new Date("2026-07-15T00:00:00");   // date + time, no offset → parsed as LOCAL time

Read that twice, because it’s genuinely counterintuitive: adding a time component flips the assumed zone. A date-only ISO string is interpreted as UTC. The moment you append a time with no offset, the standard switches to interpreting it in the runtime’s local zone. Same-looking strings, different instants.

The visible damage is the calendar shift. Suppose a browser in Los Angeles (UTC−7 in summer) runs:

new Date("2026-07-15").toLocaleDateString("en-US"); // "7/14/2026"

You parsed “July 15th,” and the screen shows July 14th. Nothing is broken — the string was read as 2026-07-15T00:00:00Z, which in Pacific time is 5:00 PM on the 14th — but to a user it looks like the app is a day behind. Any negative-offset zone (all of the Americas) can produce this, which is why the bug tends to reach production undetected from a team in UTC+something.

The defense is a single habit: never rely on the default zone — put an explicit offset in every date-time string. Write 2026-07-15T00:00:00Z when you mean UTC, or 2026-07-15T00:00:00-07:00 when you mean a specific local moment. With the offset present, every parser agrees on the instant, and the date-only-versus-date-time trap disappears entirely.

Off by a day for a different reason: calendar math and DST

Even when parsing is clean, arithmetic on dates has traps a raw integer doesn’t. The instant is a number, so differences between instants are safe — subtract two timestamps and you get an exact count of seconds, no zone required. The danger is doing math on the rendered calendar instead.

Two ways it bites:

  • “Add 24 hours” is not “the same wall-clock time tomorrow.” On the days a region springs forward or falls back for daylight saving, the local day is 23 or 25 hours long. Add 86,400 seconds to 9:00 AM the day before a spring-forward and you land on 10:00 AM, not 9:00. If you actually want “9 AM tomorrow, whatever the offset,” you have to do that reasoning in the local calendar, not by adding a fixed number of seconds.
  • Some wall-clock times don’t exist, and some happen twice. When clocks spring forward, the skipped hour (say 2:30 AM) is a local time that never occurs that day; when they fall back, the repeated hour happens twice, so a bare local time in that window is ambiguous about which instant it means.

The reliable pattern is the familiar one: do comparisons and durations on the zone-neutral instant, and touch the local calendar only for display or for genuinely calendar-based questions (“how many days until the 1st?”), using a time-zone-aware library for those rather than hand-rolled arithmetic. For the common, undramatic version of this — how many days between two dates, what’s the date 90 days out, how old is something — a date calculator does the counting without you having to reason about whether a DST boundary fell in the middle.

Breadth: the year 2038 problem

There’s an upper wall built into a lot of older systems. If a timestamp is stored as a signed 32-bit integer of seconds, the largest value it can hold is 2^31 − 1 = 2147483647, which as a Unix time is:

2038-01-19T03:14:07Z

One second later, the counter overflows past the top of a 32-bit signed integer and wraps around to a large negative number — flipping the date back to December 1901. This is the Year 2038 problem (“Y2038” or “the Unix epochalypse”), the direct descendant of Y2K, and it’s the reason time_t on modern 64-bit systems was widened to 64 bits (which pushes the wall roughly 292 billion years out — safely never). It still lurks in embedded devices, old binary file and network formats, and databases with a 32-bit epoch column — for example, MySQL’s TIMESTAMP type historically topped out at 2038, while DATETIME did not. If a far-future date silently collapses to 1901, a 32-bit overflow is the first suspect.

The same signedness cuts the other way for the past: because the integer is signed, dates before 1970 are legal and simply negative (-1000000000 is April 1938). Code that wrongly assumes a timestamp is unsigned will mangle any pre-epoch date — a real concern for birthdays, historical records, and anything that reaches back before the 1970s.

Breadth: not everything counts from 1970

One last source of “off by a wild amount” that isn’t a seconds/milliseconds slip: not every system that calls something a “timestamp” counts from the Unix epoch. The unit can differ and the origin can differ. A few you’ll actually meet:

  • Spreadsheets (Excel, Google Sheets) count days since roughly 1899–1900, not seconds since 1970 — so a date pasted out of a spreadsheet as a raw serial number is on a completely different scale and origin.
  • Windows file times count 100-nanosecond intervals since the year 1601.
  • NTP, the network time protocol, counts seconds since 1900.

So before you trust any conversion, pin down both coordinates: the unit (seconds? milliseconds? days? 100-ns ticks?) and the epoch (1970? 1900? 1601?). A value that’s off by decades in a way that a ×1000 can’t explain is often an epoch mismatch, not a unit mismatch. When you can’t tell what you’re holding, running the raw number through a timestamp converter and checking whether the Unix-epoch interpretation lands on a sane date is the fastest way to rule the common case in or out.

The checklist for a wrong timestamp

Next time a time comes out wrong, don’t start multiplying and subtracting at random. Walk this in order — it isolates almost every case:

  1. What unit is it? Count the digits. In this era, 10 ≈ seconds, 13 ≈ milliseconds. If a “now” value produced a date in 1970, you gave seconds to something wanting milliseconds (multiply by 1000); if it produced a date tens of thousands of years out, do the reverse.
  2. Instant or rendering? The stored number has no time zone. If the time is off by whole hours, you’re almost certainly formatting a correct instant in the wrong zone — fix the zone at display time, never by adding hours to the stored value.
  3. Parsing a string? Does it carry an offset? A date-only string is read as UTC; a date-time string with no offset is read as local — the source of most “off by a day” bugs. Put an explicit Z or ±hh:mm on every date-time string.
  4. Doing date math? Subtracting two instants is safe. Adding calendar amounts (“tomorrow,” “next month”) on local time is not, because of DST-length days — do it on the instant, or with a zone-aware library, and let a tool handle plain day counting.
  5. Off by decades and unit is right? Suspect a different epoch — a spreadsheet serial, a 1601-based file time, an NTP value — not a Unix timestamp at all. And if a far-future date collapsed to 1901, that’s a 32-bit signed overflow.

Underneath all five is the single split from the top: the instant is one zone-neutral number; the date on your screen is a rendering of it in some zone. The number is almost never the thing that’s wrong. What’s wrong is a unit crossed on the way in, or a zone crossed on the way out — and once you can say which of those happened, the timestamp stops being mysterious and becomes a one-line fix.