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 UTCis-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
- Store in UTC: Use
timestamptz/DATETIMEholding UTC, or an integer timestamp — never a "local time" column. - Transmit integer timestamps or ISO 8601 with an offset: Avoid tz-less strings like
2023-11-15 06:13:20. - Format at display time in the user's zone: Use
Intl.DateTimeFormat(frontend) or a formatter with an explicitZoneId(backend). - Agree on units explicitly: Document seconds vs milliseconds, suffix fields with
_ms, and let the converter auto-detect as a fallback. - Never do math on wall-clock time: Differences, schedules, and sorting belong on the integer timestamp.
- Store IANA identifiers, not offsets: Put
Asia/Shanghaiin the database, not+08:00. - 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.