Time is one of those things that looks trivial until you try to store it, compare it, or send it across a network. A surprising number of production bugs trace back to a developer treating a timestamp as a plain number when it is really a number plus a stack of assumptions. This guide walks through what a Unix timestamp actually represents, where the units trip people up, and how to convert between formats without quietly corrupting your data.
What epoch time actually is
A Unix timestamp is a single integer: the number of seconds that have elapsed since midnight UTC on January 1, 1970. That instant is called the epoch. So
010000000001750000000The key property is that a Unix timestamp is absolute and location-free. It does not carry a time zone, a date format, or a notion of "local time." It is just a count of seconds from a fixed reference point. Two machines on opposite sides of the planet that read the same instant will produce the same Unix timestamp, regardless of what their wall clocks say.
That is exactly why epoch time is the lingua franca of computing. Comparing two timestamps is just integer comparison. Computing a duration is subtraction. Sorting events is sorting numbers. None of it requires parsing or locale awareness.
One subtlety worth knowing: Unix time deliberately ignores leap seconds. A Unix day is always exactly 86,400 seconds, even though the actual astronomical day occasionally needs an extra second. This keeps the math clean at the cost of not being a perfect count of real elapsed seconds. For almost all application work this is the correct tradeoff, and you should not try to "fix" it.
Seconds vs milliseconds: the unit trap
The most common timestamp bug is a units mismatch. Unix time is canonically measured in seconds, but plenty of platforms hand you milliseconds instead:
- C , Pythontext
time()(returns a float of seconds), Gotexttime.time(), PostgreSQLtexttime.Now().Unix()→ secondstextextract(epoch ...) - JavaScript andtext
Date.now()→ millisecondstextnew Date().getTime() - Java → millisecondstext
System.currentTimeMillis()
The failure mode is sneaky because both values look like plausible numbers. A seconds value interpreted as milliseconds puts you in January 1970. A milliseconds value interpreted as seconds puts you tens of thousands of years in the future.
A quick sanity check: a current seconds-based timestamp is 10 digits (until the year 2286), and a current milliseconds-based timestamp is 13 digits. If you see a 13-digit "second" or a 10-digit "millisecond," something upstream is wrong.
javascriptconst secs = 1750000000; const ms = 1750000000000; // WRONG: passing seconds to a ms-based constructor new Date(secs); // 1970-01-21T... — way off // RIGHT new Date(secs * 1000); // correct instant new Date(ms); // correct instant
Write the unit into your variable names (
expiresAtMscreatedAtSecUTC vs local time
Here is the mental model that prevents most time-zone bugs:
💡A Unix timestamp is an instant. A time zone is a lens you look at that instant through. The instant never changes; the lens changes how it reads.
The timestamp
175000000013:46America/New_York09:46Asia/Kolkata19:16The practical consequences:
- Store and transmit in UTC (or raw epoch). Never persist "local" times without the offset; you lose the information needed to reconstruct the instant.
- Convert to local only at the edge — the moment you render something for a human. Do the rest of your computation in UTC.
- A time zone is more than an offset. istext
Asia/Kolkata, buttext+05:30istextAmerica/New_Yorkin winter andtext-05:00in summer because of daylight saving time. Store the zone name (text-04:00), not a fixed offset, whenever future local times matter — for example, a recurring 9 a.m. meeting that must stay at 9 a.m. across a DST transition.textAmerica/New_York
This is why "store the offset" is not enough for scheduling. An offset tells you the rule for one instant; a zone name carries the full ruleset, including DST changes that may not have even been legislated yet.
The year 2038 problem
The classic Unix timestamp is stored in a signed 32-bit integer. That gives a range of about ±2.1 billion seconds from the epoch. The positive ceiling is reached at 03:14:07 UTC on January 19, 2038, after which the counter overflows and wraps around to a large negative number — interpreted as December 1901.
This is the Y2K of the systems world. Any code still using a signed 32-bit
time_tThe fix is straightforward: use a 64-bit integer for time. A signed 64-bit count of seconds covers roughly 292 billion years in each direction, which comfortably outlasts any practical concern. Most modern systems have already migrated:
- 64-bit Linux, macOS, and the major language runtimes use 64-bit time internally.
- Embedded systems, legacy databases, old binary file formats, and network protocols with fixed-width 32-bit time fields are where the risk still lives.
If you maintain anything that packs a timestamp into exactly four bytes, that is the code to audit. The bug will not announce itself early; it appears the first time a date crosses the boundary.
ISO 8601: the human-readable companion
Raw epoch integers are perfect for machines and useless for humans. ISO 8601 is the standard string format that bridges the gap, and it is what you should use in APIs, logs, and config files. A typical value looks like this:
text2026-06-02T14:30:00Z 2026-06-02T20:00:00+05:30
The parts:
YYYY-MM-DDTHH:MM:SSZ+05:30-04:00Why ISO 8601 is worth standardizing on:
- It sorts correctly as plain text. Because the fields run largest to smallest, lexical string sorting matches chronological order. This is genuinely useful in logs and filenames.
- It is unambiguous. is March 4th or April 3rd depending on where you live;text
03/04/2026is never in doubt.text2026-03-04 - It carries the offset. Unlike a bare epoch number, the string tells a reader the zone context explicitly.
The one rule to internalize: an ISO 8601 string without a
ZConverting safely in code
The golden rule across every language: be explicit about the zone at every boundary. Bugs come from implicit conversions where a library silently applies the machine's local time zone.
Python
Use timezone-aware
datetimepythonfrom datetime import datetime, timezone # epoch seconds -> aware UTC datetime (explicit tz) dt = datetime.fromtimestamp(1750000000, tz=timezone.utc) # aware datetime -> epoch seconds epoch = dt.timestamp() # parse ISO 8601 (3.11+ handles the trailing Z) dt = datetime.fromisoformat("2026-06-02T14:30:00+00:00")
Avoid
datetime.utcnow()fromtimestamp()datetime.now(timezone.utc)JavaScript
Remember everything is in milliseconds, and a
Datejavascriptconst dt = new Date(1750000000 * 1000); // seconds -> ms dt.toISOString(); // "2026-...Z" — always UTC, safe to store dt.getTime(); // epoch ms Date.parse("2026-06-02T14:30:00Z"); // -> epoch ms // localized display only — at the rendering edge dt.toLocaleString("en-US", { timeZone: "America/New_York" });
Use
toISOString()toLocaleString()timeZoneTemporalDateA repeatable checklist
- Decide your storage unit (epoch seconds is a fine default) and never mix it with milliseconds.
- Store and compute in UTC; convert to local only for display.
- Persist the IANA zone name () when future local times must survive DST.text
America/New_York - Emit ISO 8601 with an explicit or offset in every API and log line.text
Z - Use 64-bit storage for any new timestamp field.
- Test the boundaries: a DST transition, a leap day, and a value past 2038.
When you are debugging a stray value and just need to know what
1750000000Wrapping up
Timestamps are simple once you separate the two ideas that get conflated: the instant (an absolute epoch count) and the representation (a zoned, formatted view of that instant). Keep the instant in UTC, in a known unit, in a 64-bit field. Render with an explicit zone only at the edges. Speak ISO 8601 on the wire. Do that consistently and the entire category of "why is this event showing up an hour early" bugs simply stops happening.