← Back to Blog

Unix Timestamp Conversion Explained: Seconds, Milliseconds & Time Zones

What is a Unix Timestamp?

A Unix timestamp is the number of seconds (or milliseconds) elapsed since 1970-01-01 00:00:00 UTC (the epoch). It is an absolute instant independent of time zone — the same moment yields the same timestamp number whether you are in Beijing, London, or New York.

Representing time as a single integer lets you store, compare, and subtract moments like ordinary numbers, free from the complexity of human calendars.

Three details that are easy to miss:

  • The epoch was a choice. 1970-01-01 UTC was simply what the early Unix team picked; any other day would work identically.
  • It is signed. Times before 1970 are negative: 1969-12-31 23:59:59 UTC is -1. You meet these when handling birthdates or historical archives.
  • It is an integer, not a float. Storing timestamps as floating point introduces precision error that can break comparisons and sorting.

Precision: not just seconds and milliseconds

The most common bug in practice is mixing units. Four precisions exist in real production systems:

Precision Digits Example Common source
Seconds 10 1700000000 Linux date +%s, MySQL UNIX_TIMESTAMP(), most backend APIs
Milliseconds 13 1700000000000 JavaScript Date.now(), Java System.currentTimeMillis()
Microseconds 16 1700000000000000 Python time.time_ns()//1000, MySQL DATETIME(6)
Nanoseconds 19 1700000000000000000 Go time.Now().UnixNano(), Python time.time_ns()

Quick identification:

Digits Precision
10 seconds
13 milliseconds
16 microseconds
19 nanoseconds

Rule of thumb: 13-digit value ÷ 1000 ≈ 10-digit value. If the frontend passes a 13-digit value that the backend stores as seconds, the time jumps tens of thousands of years into the future. A converter should auto-detect precision from digit count — the timestamp converter does exactly that and renders every major time zone side by side.

⚠️ Digit-count detection fails on one real case: mistaking 16-digit microseconds for milliseconds, a 1000x error. When crossing system boundaries, encode the unit in the field name (created_at_ms) rather than relying on inference.

Time zones: UTC, GMT and offsets

The timestamp is an absolute UTC instant. When you see 2023-11-15 06:13:20 on a page, that is the UTC instant rendered in your local time zone.

const ts = 1700000000          // seconds
const d = new Date(ts * 1000)  // note ×1000 to milliseconds
console.log(d.toString())      // local: Wed Nov 15 2023 06:13:20 GMT+0800
console.log(d.toISOString())   // UTC: 2023-11-14T22:13:20.000Z

Three concepts routinely conflated:

Concept Meaning Use
UTC Coordinated Universal Time, atomic-clock based The single basis for storage and computation
GMT Greenwich Mean Time, astronomical Colloquially interchangeable with UTC; technical docs should say UTC
Offset Difference from UTC in hours, e.g. +08:00 Describes one instant only — it is not a time zone

An offset is not a time zone, and that is the subtlest source of bugs. +08:00 only says this instant is eight hours ahead of UTC. China is +08:00 year-round, but London is +00:00 in winter and +01:00 in summer. To express "where the user is", use an IANA time zone identifier (Asia/Shanghai, Europe/London) rather than an offset — the IANA tz database carries the historical rule changes; an offset does not.

For regional conversion, the time zone converter works off the IANA database and handles DST transitions correctly.

Always store and transmit in UTC; format for the user's time zone only at display time. This is the only rule that keeps cross-timezone systems sane.

Daylight Saving Time (DST) Pitfalls

In regions with DST, two days a year break the calendar:

Scenario Moment What happens Consequence
Spring forward local 02:00 → 03:00 02:00–02:59 does not exist A scheduled job in that hour silently never runs
Fall back local 03:00 → 02:00 02:00–02:59 occurs twice One local time maps to two instants; logs, de-duplication and aggregation all break

Calendar-based arithmetic (e.g. "add 24 hours" or "same time tomorrow") drifts by one hour on DST transition days. The correct approach: do arithmetic on the timestamp (UTC integer), then format — never add or subtract on local wall-clock time.

Use the date calculator for spans between dates or adding business days, and the cron parser to confirm when a schedule actually fires — remember cron runs in the server's local time zone, which in containers is UTC by default.

Leap Seconds

Unix time increments at a fixed 86400 seconds per day and deliberately ignores leap seconds. The OS/NTP smooths them out underneath (usually by smearing one second across several hours), transparent to almost all applications. Only extreme-precision domains (financial matching, satellite timing) need special handling.

Choosing a storage and transport format

Approach Pros Cons Use when
Integer timestamp (s/ms) Consistent across languages, fast to compare, no tz ambiguity Not human-readable; hard to aggregate by date in SQL Logs, sorting, cross-system transport
ISO 8601 with offset Readable, sorts lexicographically, self-describing Longer strings, larger indexes API responses, config files
Native DB temporal type Group by date, index-friendly, rich functions Semantics differ per engine; check tz awareness Business tables needing time-dimension aggregation
Local-time string No time zone, not comparable, not sortable ❌ Never

Per-database differences (easy to get wrong)

Database Type TZ aware? Notes
PostgreSQL timestamptz ✅ Stores UTC, displays in session tz Preferred. Plain timestamp has no tz — do not confuse them
MySQL DATETIME ❌ Stored literally Good for wall-clock values; agree on UTC if crossing zones
MySQL TIMESTAMP ✅ Stores UTC, displays in session tz Range ends 2038-01-19 and starts 1970
SQLite No native type Usually store integer timestamps or ISO 8601 text
MongoDB Date ✅ UTC milliseconds internally Drivers convert automatically

MySQL's TIMESTAMP hitting the 2038 ceiling is a real source of incidents — prefer DATETIME holding UTC, or BIGINT, for new tables.

Correct Conversion Across Languages

JavaScript (milliseconds → readable, watch the unit):

const sec = 1700000000
const d = new Date(sec * 1000)           // seconds → milliseconds
const iso = d.toISOString()              // UTC string, trailing Z
const local = d.toLocaleString('en-US')  // local-time string

// Render in a specific zone (required for SSR and multi-zone reports)
new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York',
  dateStyle: 'full', timeStyle: 'long',
}).format(d)

Python:

from datetime import datetime, timezone
ts = 1700000000
print(datetime.fromtimestamp(ts, tz=timezone.utc))  # be explicit about UTC
print(datetime.fromtimestamp(ts, tz=timezone.utc).isoformat())

Python's datetime.fromtimestamp(ts) without a tz argument interprets the value in the system's local zone — so your laptop and a UTC server disagree. This is the most common time bug discovered only after deployment.

Go:

import "time"
t := time.Unix(1700000000, 0)        // seconds
// milliseconds: time.UnixMilli(ms); microseconds: time.UnixMicro
loc, _ := time.LoadLocation("Asia/Shanghai")
fmt.Println(t.In(loc).Format(time.RFC3339))

Java:

Instant i = Instant.ofEpochSecond(1700000000L);   // seconds
Instant j = Instant.ofEpochMilli(1700000000000L); // milliseconds
System.out.println(i.atZone(ZoneId.of("Asia/Shanghai")));
// Use java.time (Java 8+); leave java.util.Date and Calendar behind

PHP:

$dt = (new DateTimeImmutable('@1700000000'))      // @ prefix means timestamp, defaults to UTC
    ->setTimezone(new DateTimeZone('Asia/Shanghai'));
echo $dt->format(DateTimeInterface::RFC3339);

Rust:

use chrono::{DateTime, Utc, TimeZone};
let dt: DateTime<Utc> = Utc.timestamp_opt(1700000000, 0).unwrap();
println!("{}", dt.to_rfc3339());

Symptom → cause → fix

Symptom Cause Fix
Everything shows 1970-01-01 A 0 or null was passed, or parsing failed silently Validate input; raise on parse failure instead of defaulting to epoch
Time jumps ~50,000 years ahead Milliseconds stored as seconds (1000x) Standardise precision; encode the unit in the field name
Correct locally, 8 hours off on the server Server is UTC, code parses as local UTC everywhere; convert only at display
User sees the wrong date by one day Displayed in UTC while the user expects local Pass the user's time zone to the display layer
A scheduled job skipped a run DST spring-forward: the target time never existed Run cron in UTC, or use "every N seconds" instead of "daily at HH:MM"
Orders collide within the same second Second precision is too coarse Move to milliseconds, or add a monotonic sequence
Times go negative after 2038 Signed 32-bit overflow Switch to 64-bit or ISO 8601
Times shift by hours after a DB migration One side stored local, the other UTC Normalise to UTC and backfill historical rows

Best Practices

  1. Store in UTC: Use timestamptz / DATETIME holding UTC, or an integer timestamp — never a "local time" column.
  2. Transmit integer timestamps or ISO 8601 with an offset: Avoid tz-less strings like 2023-11-15 06:13:20.
  3. Format at display time in the user's zone: Use Intl.DateTimeFormat (frontend) or a formatter with an explicit ZoneId (backend).
  4. Agree on units explicitly: Document seconds vs milliseconds, suffix fields with _ms, and let the converter auto-detect as a fallback.
  5. Never do math on wall-clock time: Differences, schedules, and sorting belong on the integer timestamp.
  6. Store IANA identifiers, not offsets: Put Asia/Shanghai in the database, not +08:00.
  7. Test the boundaries: pre-1970 (negative), 2038-01-19 (32-bit overflow), DST transition days, and 29 February.

Then verify with real values — paste a suspicious one into the timestamp converter. It renders UTC and the major time zones side by side, so a unit error and a time-zone error look visibly different.

Frequently Asked Questions

What is the difference between 10-digit and 13-digit timestamps?

Ten digits are Unix epoch seconds; thirteen digits are milliseconds. For the same instant, the 13-digit value is roughly the 10-digit value times 1000. JavaScript's Date.now() returns milliseconds while many backends use seconds, and mixing them produces a 1000x error.

Why does the same timestamp display differently across time zones?

A timestamp is an absolute instant in UTC and is time-zone independent; what differs is the rendered date and time, which is produced for the viewer's time zone. A good converter lets you pick the target time zone before displaying the result.

Do the front end and back end need to agree on a time zone?

They do not need to agree on a time zone — they need to agree on storing in UTC and displaying locally. Keep databases and APIs in UTC (or raw timestamps) and format in the user's time zone at the presentation layer, so server time-zone drift cannot corrupt anything.

Do leap seconds affect timestamps?

Unix time increases linearly at a fixed 86,400 seconds per day and is unaware of leap seconds; NTP and the operating system smooth them out underneath. Most business systems need no special handling, but high-frequency financial systems should be aware of the discrepancy.

Is the Year 2038 problem still a thing?

A signed 32-bit integer holding epoch seconds overflows on 2038-01-19. Modern 64-bit languages have moved past it, but legacy systems, embedded devices, and some C libraries can still be affected — prefer 64-bit values or ISO 8601 strings when exchanging times across systems.

Should I store an integer timestamp or a DATETIME column?

It depends on whether the database needs to do time arithmetic. If you group by date, aggregate by month, or query ranges with `BETWEEN`, a native temporal type (PostgreSQL `timestamptz`, MySQL `DATETIME`) reads better and indexes better — but it must be time-zone aware or carry UTC semantics. If you only sort and diff, and need consistency across languages and stores, an integer timestamp is simpler. The one thing never to do is store a local-time string: it carries no time zone and cannot be compared.

How should time be represented in JSON?

JSON has no date type, so you must agree on a string or number format. Prefer ISO 8601 with an offset (`2026-08-30T02:13:20+08:00`) or the UTC `Z` form (`2026-08-30T18:13:20Z`) — readable, sortable, and unambiguous about the time zone. An integer timestamp is also valid but unreadable when debugging. Never use a tz-less local string like `2026-08-30 02:13:20`: parsers interpret it in the runtime's time zone, so it breaks the moment you cross one.

← Back to Blog