Time guide

Unix Timestamps and Time Zone Conversion

Epoch seconds versus milliseconds, UTC versus local time, ISO 8601 versus RFC 3339, why an offset is not a time zone, how to store an instant so it survives, and what leap seconds and 2038 actually change.

Almost every time bug in a backend comes from one of four confusions: seconds mistaken for milliseconds, a wall-clock reading mistaken for an instant, a UTC offset mistaken for a time zone, or a local time stored without saying which locality. None of them are hard once the distinctions are clear, and all of them are expensive once they reach production data. This guide separates the four and shows what to store instead.

A timestamp is an instant, not a clock reading

A Unix timestamp is a single integer: the number of seconds elapsed since 1970-01-01T00:00:00Z, the epoch. It is not a date, it does not belong to a time zone, and it has no concept of a calendar. It names one instant on a universal timeline, and every machine on Earth that is correctly synchronised agrees on its value at that instant, regardless of where it sits.

The time zone enters only at the moment of display. The integer 1000000000 is 2001-09-09T01:46:40Z in UTC, and the same instant is 2001-09-08 at 21:46:40 in New York and 2001-09-09 at 09:46:40 in Shanghai. Nothing about the number changed; three renderings of it did. This is the whole distinction between an instant and a wall-clock reading, and it is the distinction that a field called created_at with type DATETIME quietly destroys.

Once you internalise that a timestamp is a point rather than a description, the correct handling of most problems follows. Differences between timestamps are meaningful without any zone information: subtract two epoch values and you get a duration in seconds, correct across daylight-saving transitions and across hemispheres. Comparisons are meaningful too, which is why event ordering, cache expiry and token lifetimes are all expressed this way.

What a timestamp cannot express is a future local commitment. "The office opens at 09:00" is not an instant — it is a rule that resolves to a different instant on different days, and it will resolve differently if the government changes the daylight-saving rules before that day arrives. Storing that as an epoch value bakes in an assumption you are not entitled to make.

Seconds, milliseconds, and the digit-count heuristic

The C library, most Unix tooling, JWT claims and Postgres extract(epoch) all use seconds. JavaScript's Date.now(), Java's System.currentTimeMillis() and most JSON APIs use milliseconds. Go's time package reaches for nanoseconds, and databases and tracing systems often use microseconds. There is no marker in the value itself that says which one you are holding.

The practical detector is the digit count, because the magnitudes are so far apart. Right now a value in seconds has ten digits and a value in milliseconds has thirteen. A ten-digit value read as milliseconds lands in January 1970: 1758412800 interpreted as milliseconds is 1970-01-21T08:26:52.800Z, which is the classic "all our records are from 1970" symptom. A thirteen-digit value read as seconds lands more than fifty thousand years in the future, which is the equally classic "expiry date in the year 57000" symptom.

This heuristic is convenient but it is not a specification. It breaks for timestamps near the epoch, for negative values representing dates before 1970, and for any code path that might legitimately receive both units. The durable fix is to name the unit in the field: expires_at_ms, not expires_at. When you inherit a field you cannot rename, convert once at the boundary of your system and never again.

All five rows describe 2026-09-21T00:00:00Z. The digit count is the fastest way to identify an unlabelled value.

UnitDigits (today)Value for 2026-09-21T00:00:00ZTypical source
Seconds101789948800C time_t, crontab tooling, JWT exp and iat, Postgres extract(epoch)
Milliseconds131789948800000JavaScript Date.now(), Java currentTimeMillis(), most JSON APIs
Microseconds161789948800000000Postgres internal storage, OpenTelemetry spans, some log pipelines
Nanoseconds191789948800000000000Go time.UnixNano(), Prometheus and Kubernetes internals
Fractional seconds10 plus a decimal1789948800.000Python time.time(), Ruby Time#to_f

ISO 8601 and RFC 3339 are not the same standard

When a timestamp has to be readable as well as machine-parsable, it is written as a string, and there are two standards involved. ISO 8601 is the broad international standard for representing dates and times; it covers calendar dates, ordinal dates, week dates, durations, repeating intervals and two different formats, extended (with separators) and basic (without). RFC 3339 is a narrow profile of ISO 8601 written specifically for internet protocols, and it is what almost every API actually means when it says "ISO format".

The differences matter in exactly one direction: every RFC 3339 timestamp is valid ISO 8601, but plenty of valid ISO 8601 is not RFC 3339. RFC 3339 requires a full date and time with an explicit offset, so 2026-09-21T00:00:00Z is fine and 2026-09-21T00:00:00 is not. It forbids the basic format, so 20260921T000000Z is out. It allows a lowercase t and z, which many parsers reject in practice. And it assigns a meaning to -00:00 that ISO 8601 does not permit at all: the instant is known, but the local offset is not.

There is also a newer extension worth knowing about. RFC 9557 adds a bracketed time zone annotation, so 2026-09-21T09:00:00+02:00[Europe/Berlin] carries both the resolved offset and the rule that produced it. This is the format JavaScript's Temporal API emits, and it is the first widely specified way to write down a future local commitment without losing information.

FormExample for the same instantValid RFC 3339?What it is good for
Extended UTC2026-09-21T00:00:00ZYesThe default choice for any API payload or log line
Extended with offset2026-09-21T08:00:00+08:00YesPreserving the offset the event was observed at
Fractional seconds2026-09-21T00:00:00.123ZYesSub-second ordering; any number of digits is allowed
Basic format20260921T000000ZNoCompact filenames and some legacy protocols
No offset2026-09-21T00:00:00NoA local wall-clock reading; ambiguous on its own
Ordinal date2026-264NoDay-of-year arithmetic in scientific and logistics data
Week date2026-W39-1NoReporting periods aligned to ISO weeks
Offset unknown2026-09-21T00:00:00-00:00YesThe instant is known but the observer's offset is not
Zone annotation2026-09-21T02:00:00+02:00[Europe/Berlin]RFC 9557 extensionA future local commitment that must survive rule changes

An offset is not a time zone

+08:00 is an offset: a fixed difference from UTC, valid for one instant. Asia/Shanghai is a time zone: a named entry in the IANA tz database that maps every instant, past and future, to an offset, including the historical periods when China observed daylight saving. They are not interchangeable, and treating one as the other is the second most common time bug after the seconds-milliseconds mix-up.

The distinction is invisible for a timestamp in the past, where the offset has already been determined and will not change. It becomes decisive for anything in the future. If you store a recurring 09:00 meeting in Berlin as +02:00, the meeting is correct until the last Sunday in October, when Berlin returns to +01:00 and every subsequent occurrence is an hour early. If you store it as Europe/Berlin plus a local time of 09:00, it stays correct — and it stays correct again if the European Union ever abolishes seasonal clock changes, because the tz database will be updated and your stored data will not need to be.

Offsets also fail to identify a location. At any given moment several zones share an offset while disagreeing about when they will leave it: America/New_York and America/Toronto are both -04:00 in summer and both -05:00 in winter, but America/Phoenix stays at -07:00 all year while America/Denver moves. Half-hour and quarter-hour offsets such as Asia/Kolkata at +05:30 and Australia/Adelaide at +09:30 are a further reminder that an offset is not always a whole number of hours, which breaks naive code that stores it as an integer count.

Storing an instant so it survives

There are three kinds of temporal value and they need three different treatments. A past event — a log line, an audit record, a payment — is an instant, and should be stored as UTC: a timestamptz column in PostgreSQL, a DATETIME or BIGINT kept in UTC in MySQL, or an RFC 3339 string ending in Z in a document store. A future local commitment — a meeting, a cut-off, a scheduled notification — should be stored as a local date-time plus an IANA zone name, in two columns, and resolved to an instant only when it is needed. A date with no time at all — a birthday, an invoice period, a public holiday — should be stored as a DATE and never given a time component, because giving it one forces a zone choice that has no correct answer.

Be careful with the database types. PostgreSQL's timestamptz does not store a zone; it converts the input to UTC on write and renders it in the session zone on read, which is usually what you want. Its timestamp without time zone stores exactly the digits given and is only safe if your whole system agrees on a zone. MySQL's TIMESTAMP converts to UTC and back using the session zone, but is limited to the 32-bit range and cannot represent anything after 2038-01-19T03:14:07Z; its DATETIME has a wider range but performs no conversion at all.

Finally, know your language's parsing rules rather than assuming them. In JavaScript a date-only string is parsed as UTC while a date-time string without an offset is parsed as local — two adjacent lines of code that differ by six characters and by several hours.

The JavaScript parsing asymmetry, run on a machine set to UTC+8
new Date("2026-09-21").toISOString()
// "2026-09-21T00:00:00.000Z"   date-only form is treated as UTC

new Date("2026-09-21T00:00:00").toISOString()
// "2026-09-20T16:00:00.000Z"   date-time without offset is treated as LOCAL

new Date("2026-09-21T00:00:00Z").toISOString()
// "2026-09-21T00:00:00.000Z"   explicit offset, no ambiguity

// Always write the offset. Then the string means the same thing everywhere.
Date.parse("2026-09-21T00:00:00Z") / 1000
// 1789948800

Leap seconds and the 2038 problem

Unix time assumes every day contains exactly 86400 seconds. Astronomical time does not cooperate, so UTC occasionally inserts a leap second, and the POSIX definition simply has no room for it: the value computed for 23:59:60 collides with a value it has already used. Real systems resolve this by repeating a second, by stepping the clock, or — the approach Google and AWS chose — by smearing the extra second across a whole day so that no clock ever goes backwards. The practical consequence is that a duration computed from two epoch values can be off by a second across a leap second, which matters for physics and for financial sequencing and for almost nothing else.

The last leap second was inserted at the end of 2016, leaving TAI ahead of UTC by 37 seconds, and in 2022 the General Conference on Weights and Measures resolved to stop inserting them by 2035. If you are writing new code, the honest position is that leap seconds are a real gap in the model, that you should not attempt to correct for them yourself, and that you should use monotonic clocks for measuring elapsed time so that no clock adjustment of any kind can produce a negative duration.

The 2038 problem is more concrete. A signed 32-bit time_t reaches its maximum at 2147483647 seconds, which is 2038-01-19T03:14:07Z. One second later it overflows to -2147483648, which renders as 1901-12-13T20:45:52Z. Any system that counts forward past that point in a 32-bit signed field does not fail with an error; it silently reports a date in the nineteenth century, which is far worse.

Modern 64-bit operating systems have moved time_t to 64 bits and are not affected in any timeframe worth discussing. What remains exposed is 32-bit embedded firmware, older filesystem inode formats, some binary protocols with a fixed four-byte time field, and MySQL's TIMESTAMP type. A subscription with a hundred-year term, a certificate with a long validity, or a far-future scheduled job will hit this today, not in 2038 — which is why the bug is usually discovered by a test with an unusually distant date rather than by the calendar.

The 32-bit boundary
2147483647  ->  2038-01-19T03:14:07Z    last representable second
2147483648  ->  2038-01-19T03:14:08Z    fine in 64-bit, overflows in signed 32-bit
-2147483648 ->  1901-12-13T20:45:52Z    what the overflow renders as

Also worth knowing:
0           ->  1970-01-01T00:00:00Z    the epoch itself
1000000000  ->  2001-09-09T01:46:40Z    a useful sanity checkpoint
8.64e15 ms  ->  +275760-09-13            the maximum a JavaScript Date can hold

A workflow that stops the bugs recurring

Most of this guide can be compressed into a handful of habits that cost nothing once they are reflexes. Convert at the edges of your system, keep one representation inside it, and make every field name state its unit so the next reader does not have to guess.

When you are debugging rather than designing, the Timestamp tool converts in both directions and renders the same instant in several zones at once, which is the fastest way to confirm that a log line and a database row really do refer to the same moment. If the value you are checking came out of a scheduler, previewing the schedule in the Cron tool in the same zone closes the loop.

  • Store instants in UTC; store future local commitments as local time plus an IANA zone name; store plain dates as DATE.
  • Put the unit in the field name: expires_at_ms beats expires_at in every code review that follows.
  • Always serialise with an explicit offset, and prefer Z over an implicit local reading.
  • Never store a UTC offset where a zone name belongs, and never assume an offset is a whole number of hours.
  • Measure elapsed time with a monotonic clock, not by subtracting two wall-clock readings.
  • Test with a date past 2038 and a date before 1970; both find bugs that ordinary fixtures never reach.

What to remember

  • A Unix timestamp names an instant, not a clock reading, so the time zone only matters at the moment you render it.
  • Count the digits before trusting an epoch value: ten is seconds, thirteen is milliseconds, and the fix is to put the unit in the field name.
  • RFC 3339 is the strict internet profile of ISO 8601, so always write a full date-time with an explicit offset and prefer Z.
  • An offset is valid for one instant; a zone name is a rule, so future local events must be stored with an IANA zone rather than a fixed offset.
  • Signed 32-bit time runs out at 2147483647, which is 2038-01-19T03:14:07Z, and overflows silently into 1901 rather than failing loudly.

Continue with related checks and tools