Unix timestamps and time zones: seconds vs milliseconds, UTC, DST and the 2038 problem

Time is the one data type everybody thinks they understand. Then a report runs an hour early for half the year, a record dated 1970 appears in the database, a meeting invite lands at 04:00, and it turns out the confusion was never about clocks — it was about units, offsets and the difference between an instant and a description of an instant. This guide covers what a Unix timestamp actually counts, how to tell seconds from milliseconds at a glance, why an offset is not a time zone, the daylight-saving bugs that come back every spring, how to write ISO 8601 that cannot be misread, and what really happens in 2038. The timestamp converter here shows all of these representations of the same instant side by side, which is usually enough to spot which one your system got wrong.

What the number counts

A Unix timestamp is the number of seconds since 1970-01-01T00:00:00Z. That instant is called the epoch, and the count is in UTC — which is the whole point. A timestamp is not “9:30 in the morning”; it is a single instant on the timeline, identical for everyone on Earth. Two people in Boston and Bengaluru who record the same moment record the same number; how they display it differs, and that display is a separate step you can redo at any time.

One subtlety worth knowing: Unix time deliberately ignores leap seconds. It pretends every day has exactly 86,400 seconds, so when a leap second is inserted the count either repeats a value or is smeared across the day, depending on the platform. The consequence is that a Unix timestamp is not a perfect count of elapsed physical seconds — it is a count of nominal days plus seconds, which is exactly what you want for calendars and not what you want for measuring durations to sub-second precision. For durations, use a monotonic clock.

Seconds or milliseconds: the ten-versus-thirteen rule

Unix time is seconds; JavaScript, Java and most JSON APIs written this century use milliseconds. The two look alike and differ by a factor of a thousand, which is why this is the most common timestamp bug there is.

ValueRead as secondsRead as milliseconds
1770000000 (10 digits)Feb 2026 — correctJan 1970 — wrong by 56 years
1770000000000 (13 digits)Year 58,000 — wrong by a lotFeb 2026 — correct

So the two symptoms are unmistakable: a date in January 1970 means seconds were handed to something expecting milliseconds, and a date tens of thousands of years in the future means the reverse. The converter applies the practical rule — anything of magnitude one trillion or more is milliseconds, anything below is seconds — and shows both conversions at once, so you can see immediately which unit produces a sensible date.

This is also the single most common way to get a JWT wrong: the exp claim is defined in seconds, and writing Date.now() into it produces a token that expires around the year 58,000. If a token never seems to expire, decode it and check the digit count.

An offset is not a time zone

This distinction causes more scheduling bugs than any other:

  • An offset is a number: +05:30, -04:00. It tells you how to convert one instant, and it is only correct for that instant.
  • A time zone is a set of rules over time: America/New_York, Europe/Berlin, Asia/Kolkata. It says which offset applies on which dates, including every historical and future daylight-saving change.

Storing -04:00 for a New York meeting is correct in July and wrong in December, when the same city is -05:00. Storing America/New_York is correct always. The zone names come from the IANA time zone database, which is updated several times a year as governments change their minds — which is also why you should let a maintained library do the arithmetic rather than hard-coding offsets anywhere.

A related trap: three-letter abbreviations are ambiguous and should never be stored. “CST” is US Central, China Standard and Cuba Standard time, “IST” is Indian, Irish and Israel Standard time. They are for display only.

Writing a timestamp that cannot be misread

ISO 8601 — in practice its stricter subset RFC 3339 — is the interchange format to use, and there is one rule that matters: always include the zone.

2026-03-18T09:30:00Z          unambiguous — UTC
2026-03-18T09:30:00+05:30     unambiguous — a fixed offset
2026-03-18T09:30:00           ambiguous — parsed as LOCAL time
2026-03-18                    ambiguous — parsed as UTC midnight
18/03/2026                    ambiguous — or is it 3 June?

The middle two are the dangerous ones, because they parse successfully and give the wrong answer. In JavaScript, a date-only string is treated as UTC while a date-and-time string without a zone is treated as local — so in New York, new Date('2026-03-18') and new Date('2026-03-18T00:00') are four hours apart. The slash form 2026/03/18 is parsed as local too, which is why a CSV import can shift every row by a day. Add the Z.

The converter’s output pane shows the same instant as local time, UTC, ISO 8601, a relative phrase (“3 hours ago”), epoch seconds and epoch milliseconds, each with a copy button. When two systems disagree about a time, putting the same value through it and comparing the six representations usually identifies the culprit in seconds.

The two nights a year that break code

Daylight saving does two things to local time, and both are bugs waiting for a scheduled job:

  • Spring: an hour that does not exist. When clocks jump from 02:00 to 03:00, there is no 02:30 that day. A date library asked to construct it will either throw, silently shift to 03:30, or produce something inconsistent. Anything scheduled in that window either runs late or does not run.
  • Autumn: an hour that happens twice.When clocks fall back, 01:30 occurs twice — an hour apart — and “01:30 local” identifies two different instants. Jobs run twice, and hourly aggregates double-count unless the data is keyed on UTC.

The rules that keep this manageable: store past events in UTC; store future local commitments as local time plus an IANA zone; do all arithmetic in UTC and convert only for display; and keep nightly jobs out of the 00:00–03:00 window. If a job absolutely must run once a day, schedule it in UTC — the cron guide covers what schedulers do to that window in more detail.

And the ugliest edge: some zones shift by 30 or 45 minutes, not an hour (Australia’s Lord Howe Island moves by 30 minutes), the southern hemisphere’s DST runs opposite to the northern, and some places have abolished it in the last few years. Any assumption of the form “offsets are whole hours” or “summer means later” is wrong somewhere.

2038, and where 32-bit time still lives

A signed 32-bit integer counting seconds runs out at 2147483647, which is 03:14:07 UTC on 19 January 2038. One second later it wraps to negative and the date reads as December 1901. Sixty-four-bit time solves it for the next 292 billion years, and modern operating systems and languages have moved — but the pattern persists in three places worth checking:

  • MySQL’s TIMESTAMP column, which is 32-bit and cannot store a date past that instant at all. DATETIME reaches the year 9999. A subscription table with a 30-year term will hit this now, not in 2038.
  • Embedded and industrial devices with long service lives and 32-bit time_t, which frequently cannot be updated at all.
  • Old file formats and protocols with a 32-bit time field baked into the layout.

You can watch it happen: put 2147483647 into the converter and it reads back as 19 January 2038, 03:14:07 UTC. Add one and you are past the edge that a lot of software has never been tested against.

Storing time properly

NeedStore
A past eventUTC instant — Postgres timestamptz, MySQL DATETIME in UTC, or 64-bit epoch
A future appointment in a placeLocal date-time plus the IANA zone name, in two columns
A birthday or a calendar dateA plain DATE. It has no time zone and should never acquire one
A durationAn integer of seconds or milliseconds — never two timestamps you subtract across a DST boundary
An audit or ordering keyUTC with sub-second precision; consider a UUIDv7, which embeds the millisecond

A note on Postgres, because the naming misleads: timestamptz does not store a zone. It converts the input to UTC, stores that, and converts back on output using the session zone. timestamp without time zonestores the wall-clock text you gave it and does no conversion at all — which is right for “09:00 local, wherever that is” and wrong for everything else.

Using the converter

The page shows the current epoch, ticking each second, with a copy button — handy when you need a plausible timestamp for a test fixture. Paste any epoch value, in either unit, and it expands into all six representations. Or use the date picker to go the other way: choose a local date and time and read off the epoch seconds, epoch milliseconds and ISO 8601 form.

All of it is computed in the page using your browser’s own zone data and the standard internationalisation APIs, so the “local time” line is genuinely your machine’s idea of local — which is the useful thing to compare against when a server disagrees with a user about what time something happened.

Do this

  • Count the digits before believing a timestamp: ten is seconds, thirteen is milliseconds.
  • Always write the zone into an ISO string — Zor an explicit offset. Never rely on a parser’s default.
  • Store an IANA zone name, never a bare offset and never a three-letter abbreviation.
  • Store past events in UTC; store future local commitments as local time plus zone.
  • Do arithmetic in UTC, convert only for display, and keep daily jobs away from 00:00–03:00 local.
  • Avoid MySQL TIMESTAMP for anything that can reach 2038; use DATETIME or 64-bit epoch values.
  • Use a maintained date library and keep its time-zone data updated — the rules genuinely change several times a year.

Frequently asked questions

How do I tell whether a timestamp is in seconds or milliseconds?

Count the digits. A current timestamp in seconds has ten; in milliseconds, thirteen. If a date comes out in January 1970 you fed seconds to something expecting milliseconds; if it lands tens of thousands of years in the future, you did the reverse. The converter here uses the same rule — anything at or above a trillion is treated as milliseconds.

Why does new Date("2026-03-18") give me the wrong day?

Because a date-only ISO string is parsed as UTC midnight, while a string with a time but no zone is parsed as local. So in New York, "2026-03-18" is 20:00 on the 17th local, and "2026-03-18T09:30" is 13:30 UTC. Always include an explicit Z or offset, and the ambiguity disappears.

Is UTC the same as GMT?

Close enough for almost everything, and not identical. GMT is a time zone that some countries observe; UTC is an atomic time standard that never changes with the seasons. The practical difference is that “GMT” in a European context may mean a zone that shifts to BST in summer, while UTC never shifts. Say UTC when you mean UTC.

Should I always store times in UTC?

For anything that has already happened, yes — a past event has one true instant. For future events tied to local time, store the local time plus the IANA zone name instead. Governments change daylight-saving rules with a few months’ notice, and a meeting stored as UTC will silently shift by an hour when they do, while “09:00 in Europe/Berlin” stays correct.

What actually breaks in 2038?

Any system still storing time as a signed 32-bit count of seconds overflows at 03:14:07 UTC on 19 January 2038 and wraps to 1901. Modern 64-bit systems are fine, but the pattern survives in embedded devices, old file formats and — the one most likely to affect you — MySQL’s TIMESTAMP column, which cannot represent a date past that instant. Use DATETIME or a 64-bit type.

Tools used in this guide

Every one of these runs in your browser — the files you work on never leave your device.

More developer guides