Understanding Unix Timestamps: Epoch Time Explained for Developers

Rahmat Ullah profile photoRahmat Ullah
11 min readDeveloper Tools, Time, Systems Programming

I spent an embarrassing amount of time last winter debugging a scheduled job that ran perfectly every other weekday but kept crashing on Fridays. The cron table looked fine, the code looked fine, the logs were useless. The actual culprit was a 32-bit time_t buried inside a legacy C extension, quietly truncating a future expiry date and wrapping into 1901. That experience sent me down a rabbit hole of ISO standards, POSIX specifications, and filesystem internals, and I came out with a much deeper appreciation for the humble Unix timestamp. It looks like a number. It is a lot more than a number.

A Unix timestamp looks like a number because, at the bit level, that is exactly what it is. But wrapped around that integer is a 55 year old specification, a tangle of kernel code, several competing conventions for precision, a famous overflow bug scheduled for January 2038, and an unresolved political argument about whether a second should always be a second. If you want to stop hitting the weird edge cases that show up in authentication tokens, database migrations, and cron jobs, you have to learn what the number really represents. When you just need a quick number-to-date answer you can always paste into the StackConvert epoch time converter, but the rest of this article explains what that number is telling you.

A Brief History of the Unix Epoch

The Unix epoch is the moment at 00:00:00 UTC on January 1, 1970. Every Unix timestamp you have ever seen counts forward from that instant. But why 1970? Why not the year the Roman calendar started, or the birth of Christ, or the Gregorian cutover in 1582? The answer is pragmatism mixed with a bit of historical accident.

In the early 1970s, a small team at Bell Labs was building the Unix operating system on a PDP-11. They needed a system-wide representation of time that was cheap to compute and did not waste memory. Calendar math is genuinely expensive if the computer has to check for leap years, variable month lengths, and timezone offsets on every operation. Storing a single integer counting elapsed seconds avoids all of that. The only question was where to start counting.

The original Unix time had the epoch at January 1, 1971, and used 60 Hz ticks rather than seconds. Doing the math, a 32-bit unsigned counter of 60 Hz ticks overflows in a little over two years. That was clearly useless for anything with a lifetime. So the designers moved to whole seconds and a round epoch, and 1970 was the closest round year in the past that covered their use cases. POSIX later locked it in, and every major operating system, programming language, database, and protocol that came afterward has adopted the same reference point.

The formal specification lives in POSIX.1 (also known as IEEE Std 1003.1). The current version is POSIX.1-2017, which defines a "seconds since the Epoch" value with its own peculiar arithmetic rules. In plain English, the standard tells implementations to pretend every day is exactly 86,400 seconds long, regardless of what the actual Earth is doing. We will come back to why that sentence matters when we get to leap seconds.

Heads up: Some older Windows APIs and Microsoft file formats use a different epoch entirely. FILETIME counts 100-nanosecond intervals since January 1, 1601. Excel defaults to December 30, 1899. OLE Automation uses the same. If you are importing timestamps from a Microsoft ecosystem and seeing dates that are off by decades, odds are an epoch conversion got skipped somewhere.

How time_t Actually Works Under the Hood

In C (and in the C APIs that Unix exposes to other languages), a Unix timestamp has a concrete type called time_t. It is declared in the standard header <time.h>. What POSIX does not specify is the underlying integer size or signedness. That is a portability trap.

On a 32-bit Linux system compiled before the Y2038 fixes landed, time_t was typically a signed 32-bit integer, which gives you a range from roughly December 1901 to January 2038. On a 64-bit Linux system today, time_t is a signed 64-bit integer, which covers dates roughly 292 billion years in either direction. macOS followed the same path. Windows 64-bit has used a 64-bit __time64_t since Visual Studio 2005. Embedded systems and older firmware, however, still ship with the 32-bit version all over the place.

A simple way to see what your system is doing is a few lines of C:

#include <stdio.h>
#include <time.h>
#include <limits.h>

int main(void) {
    printf("sizeof(time_t) = %zu bytes\n", sizeof(time_t));
    printf("time_t is %s\n",
           ((time_t)-1 < 0) ? "signed" : "unsigned");
    time_t now = time(NULL);
    printf("current value = %lld\n", (long long)now);
    return 0;
}

On a modern Linux or macOS box you will see 8 bytes, signed, and a ten digit number starting with 1. On a 32-bit ARM microcontroller you might see 4 bytes, and that is where the trouble starts.

The time() function returns the current time_t. The inverse conversions are gmtime() for UTC broken-down time (year, month, day, and so on) and localtime() for the system's configured local time. Broken-down time is represented by a struct tm, which is where fields like tm_year, tm_mon, and the famously strange tm_yday live. Most bugs with Unix time in C come from people confusing time_t with struct tm, or from forgetting that localtime() is not thread safe on many platforms.

Seconds, Milliseconds, Microseconds, Nanoseconds

The original Unix timestamp counted whole seconds because that was all the hardware could usefully deliver. Modern systems have much more precise clocks, and every major language has picked a different spot on the precision ladder to be its default. That is how we end up with APIs that disagree about what the current time is by a factor of a thousand.

UnitDigits todayWhere you see it
Seconds10 digitsC time(), Python time.time() (integer part), Go Unix(), POSIX APIs, JWT exp claim
Milliseconds13 digitsJavaScript Date.now(), Java System.currentTimeMillis(), Kafka records
Microseconds16 digitsPython datetime, Postgres timestamp, gettimeofday() before monotonic clocks
Nanoseconds19 digitsGo time.UnixNano(), clock_gettime(), eBPF timestamps, tracing systems

The reason JavaScript went straight to milliseconds has a specific history. When Brendan Eich designed the original Date object in 1995, he picked milliseconds because the Java AWT library had done the same, and Java was the cool new thing at Netscape that week. Python chose seconds because it was following the POSIX time() convention. Go chose both: time.Unix() returns seconds, time.UnixMilli() milliseconds, time.UnixNano() nanoseconds, all from the same library.

The practical consequence is that if you do not know the convention of the system you are talking to, you cannot interpret the number. A ten digit value can only mean seconds (1970 to 2286 fits in ten digits). A thirteen digit value is almost always milliseconds, but could in theory be seconds from a system still going in the year 33658. A sixteen digit value is almost always microseconds. A nineteen digit value is nanoseconds.

Pro tip: When you are handed a timestamp by some new API, divide by 1000 until the ten digit number lands somewhere in the current decade. Nine out of ten times that gives you the unit. The other one out of ten is a custom format that uses an epoch other than 1970, and only careful reading of the API docs will save you.

The Year 2038 Problem in Depth

A signed 32-bit integer maxes out at 2,147,483,647. Interpret that as seconds since 1970 and you land on January 19, 2038, at 03:14:07 UTC. One tick later, the counter wraps to the most negative value a signed 32-bit integer can hold, which represents December 13, 1901. This wrap is commonly called Y2038 or the Epochalypse.

Unlike Y2K, which was largely a problem about how dates were formatted and displayed, Y2038 is a problem about how dates are stored. A display fix does not help. You have to change the underlying type to at least 64 bits, then rebuild and redeploy every piece of software that touches the value, then migrate every on-disk copy. That is a much bigger surface area.

Concrete places where this still matters in 2026:

  • Filesystem inodes. The original ext2, ext3, and early ext4 inode format stored times as 32-bit signed integers. Linux kernel 5.1 introduced 64-bit nanosecond ext4 timestamps only when the filesystem was created with inode_size of at least 256 bytes. Any ext4 volume formatted before about 2019 probably still has the old format, and upgrading it is a filesystem rebuild.
  • Database INT columns. MySQL's TIMESTAMP type is internally stored as seconds since epoch and was a 32-bit signed integer through MySQL 8.0.28. The fix landed in MySQL 8.0.35 as part of the Y2038 preparation, but schemas designed before then are still out there.
  • JWT expiry claims. The exp field in a JSON Web Token is defined by RFC 7519 as a NumericDate (seconds since the epoch). Many embedded JWT libraries and older token signing services truncate to 32 bits internally. If you ever issue a long-lived refresh token, that truncation will silently happen.
  • Cron and at schedulers. The at daemon on older BSDs and some embedded Linux cron variants still parse schedule files with 32-bit time_t. Anything scheduled past 2038 gets interpreted as a past event and either fires immediately or never.
  • GPS receivers. GPS time has its own rollover (the 1024 week cycle), but many receivers expose output in Unix epoch form through a 32-bit integer and will misbehave past 2038 unless firmware is updated.

The fix in user code is straightforward: use 64-bit integer types, use library functions that accept and return those types, and make sure your serialization format has enough room. The fix in legacy systems is the hard part, because there is no way to know a 32-bit value is about to overflow without actually auditing the code path.

Leap Seconds and Why POSIX Lies About Time

Here is a claim that surprises most developers the first time they hear it. A Unix timestamp is not the number of seconds that have actually elapsed since 1970. It is the number of seconds POSIX pretends have elapsed, which can differ from real elapsed seconds by up to about half a minute.

The difference is leap seconds. Earth's rotation is slightly irregular and is also gradually slowing down due to tidal friction with the moon. Atomic clocks keep perfect time regardless. To keep UTC (which is what wall clocks display) aligned with solar time, the International Earth Rotation and Reference Systems Service periodically inserts a leap second into UTC. Between 1972 and the end of 2025, 27 leap seconds have been added. The most recent one was on December 31, 2016.

POSIX explicitly says the "seconds since epoch" value does not count leap seconds. The standard arithmetic is: pretend every day has exactly 86,400 seconds, multiply by the number of days since 1970, add the hours, minutes, and seconds within the current day. That is the timestamp. Which means on days when a leap second is inserted, the Unix clock either stops or repeats a second, and you have two ways to handle it:

  • Step. At the moment of the leap second, rewind the clock by one second so you never see 23:59:60. This is what most stock Linux kernels did historically. It causes havoc with anything that assumes time moves monotonically forward, including database transactions, log sorting, and cache invalidation.
  • Smear. Spread the leap second across a longer window, typically 24 hours or 20 hours, so each regular second during the window is slightly longer than a real second. Google announced its leap smear approach in 2011, Meta rolled its own, and AWS followed with a 24-hour smear on its NTP fleet. Time never goes backward and never repeats.

The smearing approach has become the de facto standard for anyone running a large distributed system. It sidesteps a whole category of bugs. The downside is that during a smear, two machines with different smear algorithms disagree about the current time by up to a full second, which matters if you are doing TLS certificate validation across them. In practice, this is rarely a problem because smear windows are predictable and certificates have minute-level tolerance.

In late 2022 the International Bureau of Weights and Measures voted to stop adding new leap seconds by 2035 or shortly afterward. If that holds, leap second handling becomes a historical footnote instead of a recurring operational event.

Watch out: If your application needs to measure physical durations accurately (scientific instruments, network latency measurements, GPS-dependent logic), do not use a Unix timestamp for that. Use a monotonic clock. In C, that is clock_gettime(CLOCK_MONOTONIC, ...). In Go, it is implicit in time.Now(). In Java, it is System.nanoTime(). A monotonic clock has no relationship to calendar time and will never go backward, even if the wall clock does.

JavaScript's BigInt Timestamp Problem

JavaScript has a single numeric type, Number, which is an IEEE 754 double. The largest integer it can represent exactly is 2^53 - 1, or 9,007,199,254,740,991. This is the famous Number.MAX_SAFE_INTEGER. Any integer larger than this gets rounded when stored or read.

For millisecond timestamps this is fine. The maximum safe millisecond timestamp is about the year 287000. For microsecond timestamps it is still fine, around the year 2255. For nanosecond timestamps, it is a disaster. One nanosecond-precision value today, 1745280000000000000, is already past MAX_SAFE_INTEGER. Paste that into a JavaScript JSON.parse() call and you will get back a rounded value, not the exact number. The last two or three digits may be wrong.

// Round trip through JSON loses precision on large integers
const raw = '{"ts": 1745280000000000123}';
const parsed = JSON.parse(raw);
console.log(parsed.ts);  // 1745280000000000000  (last 3 digits wrong)
console.log(Number.MAX_SAFE_INTEGER);  // 9007199254740991

// The fix: parse as BigInt
const bigParsed = JSON.parse(raw, (key, value) =>
  typeof value === 'number' && key === 'ts' ? BigInt(value) : value
);  // Still loses precision at the Number stage - need a real BigInt parser

The real fix is to never let the value become a regular Number. Some JSON libraries, like json-bigint, parse large integers directly into BigInt. Otherwise you have to transmit nanosecond timestamps as strings and parse them manually. Go and Rust APIs that emit nanosecond precision should serialize as strings by default when they know a JavaScript client might read them.

BigInt itself became a first-class JavaScript value in ES2020. It has arbitrary precision, so timestamp arithmetic never overflows. The catch is that BigInt and Number cannot be mixed in arithmetic without an explicit conversion, and most of the Date object's methods still expect Number. So the common pattern is: parse and store as BigInt, convert to Number only when you are about to do something Date-related and you have verified the value fits in 53 bits.

Timestamps in Filesystems, Databases, and Protocols

Every layer of a typical stack has an opinion about how timestamps are stored. A request might pick up a time representation from the browser, hand it to the backend, persist it to a database, write a log line, and eventually end up as mtime on a file. Each of those transitions is a potential failure point.

Filesystems

Modern filesystems store three or four per-inode timestamps: access time (atime), modification time (mtime), change time (ctime), and sometimes birth time (btime or creation time). ext4 with 256-byte inodes uses 64-bit nanosecond precision. XFS has always used 64-bit values. ZFS and APFS both use 64-bit nanoseconds. FAT and exFAT, still in use on many SD cards and USB sticks, store timestamps in a packed 16-bit date plus 16-bit time format with two-second resolution, based on a 1980 epoch. That is why a file copied from a FAT drive often has an even-second mtime.

Databases

SQL has multiple date and time types, and the differences matter. MySQL's TIMESTAMP stores UTC seconds and is the only SQL type with automatic timezone conversion on read. MySQL's DATETIME stores wall-clock time with no timezone awareness at all. PostgreSQL has TIMESTAMP WITH TIME ZONE (internally UTC microseconds) and TIMESTAMP WITHOUT TIME ZONE (wall-clock microseconds). SQL Server has DATETIME, DATETIME2, and DATETIMEOFFSET, with the third being the one to use when you want timezone-aware UTC storage.

Wire protocols

For JSON APIs, RFC 3339 (a strict subset of ISO 8601) is the standard for representing a moment as text. A value like 2026-04-22T17:30:00Z is unambiguous, sortable lexicographically, and readable to humans. That is the format you want to use in public APIs unless you have a specific reason to send a numeric timestamp. Numeric epoch seconds are still common in older APIs, logging systems, and anywhere bandwidth matters.

Protocol Buffers (used by gRPC) defines a google.protobuf.Timestamp message containing seconds (int64) and nanos (int32). MessagePack has a native timestamp type that encodes as two concatenated fields. Avro has both millisecond and microsecond integer representations. Each of them separates the two halves to avoid JavaScript's large integer problem.

Best Practices for Handling Time in 2026

After all of the above, a short list of rules that keep you out of trouble most of the time:

  • Always store timestamps in UTC. Convert to local time only at display. The moment you let a local timestamp into persistent storage you invite DST bugs, timezone changes, and irrecoverable ambiguity.
  • Use 64-bit integer types everywhere. In application code, in schema definitions, in protocol buffers. There is no practical reason to use 32-bit time values in new software, and the cost of fixing it later is enormous.
  • Prefer RFC 3339 strings for external APIs. They are self-describing and they cannot overflow. Reserve epoch integers for internal storage and cases where byte count truly matters.
  • Use a monotonic clock for durations. Never compute "how long did this take" by subtracting two wall-clock timestamps. The wall clock can jump forward or backward due to NTP corrections, daylight saving shifts on misconfigured boxes, or leap second handling. A monotonic clock cannot.
  • Be explicit about units. If your API returns a timestamp, document whether it is seconds, milliseconds, microseconds, or nanoseconds. Name the field created_at_ms or created_at_ns if the unit is non-obvious. This single habit prevents an entire class of bugs.
  • Test for 2038 and beyond. Set your system clock to just before January 19, 2038 in a container or a VM. Run your full integration suite against it. Any 32-bit truncation will reveal itself immediately.

If you just need a quick lookup to translate a mystery number in an API response, the Unix timestamp converter handles seconds, milliseconds, and other common precisions directly in the browser. For the long hand version with example code in Python, JavaScript, Java, and PHP, the companion Unix timestamp converter walkthrough covers conversions step by step, and the time zone converter guide picks up the story once you need to display these values to users in different parts of the world.

Frequently Asked Questions

Is a Unix timestamp the same as ISO 8601 time?

No. A Unix timestamp is a single integer counting seconds (or milliseconds, or some other unit) from a fixed reference point. ISO 8601 is a text format like 2026-04-22T17:30:00Z that encodes a date, a time, and a timezone offset. You can convert between them in either direction without losing information (up to the precision of the underlying representation), but they are not the same thing. RFC 3339 is a stricter subset of ISO 8601 used by most internet protocols.

What is the difference between UTC, GMT, and epoch time?

UTC (Coordinated Universal Time) is the modern standard for civil time. GMT (Greenwich Mean Time) is an older term that for most practical purposes means the same thing, though technically GMT refers to a specific observatory-based solar time. Epoch time is a numeric representation that measures seconds since a fixed reference point (1970 for Unix). All Unix timestamps are effectively UTC because the epoch is defined in UTC, but they have no inherent timezone information attached.

Can I represent times before 1970 with a Unix timestamp?

Yes, if the underlying type is signed. A negative Unix timestamp refers to a point before January 1, 1970. A value of -86,400 represents December 31, 1969 at midnight UTC. Many libraries and systems handle this correctly, but some choke on negative values, especially on Windows and in older JavaScript engines. Test before relying on it.

Why do some Unix timestamps have a decimal point?

A decimal part represents sub-second precision. time.time() in Python returns a float with microsecond precision on most platforms. gettimeofday() in C returns a struct with seconds and microseconds fields, which some wrappers flatten into a single decimal value. If you only need whole-second accuracy, truncate or round the fractional part.

Does a Unix timestamp change when daylight saving time starts or ends?

No. Unix timestamps are always in UTC, which does not observe daylight saving. The number keeps ticking forward at one per second regardless of what local clocks are doing. The appearance of a time jump happens only when you convert to a local wall-clock representation, which is a display concern, not a storage concern.

How do I generate a Unix timestamp in a shell script?

On Linux and modern macOS, date +%s prints the current Unix timestamp in seconds. For milliseconds, date +%s%3N works on GNU date but not BSD date. For nanoseconds, date +%s%N. If you need cross-platform shell code, fall back to python3 -c "import time; print(int(time.time()))" which works anywhere Python is installed.

What happens if I store a timestamp in a 32-bit column today?

It works fine right now. It will keep working until January 19, 2038. At that moment, the next timestamp inserted will either be rejected by the database, get silently stored as a negative number, or wrap around depending on the column type. None of those outcomes are what you want. Migrate the column to a 64-bit type or a native timestamp type while you still have years to do it cleanly.

Why does my Go nanosecond timestamp lose precision in my JavaScript client?

Because JavaScript's Number type cannot represent integers larger than 2^53 - 1 exactly, and a nanosecond timestamp as of today is already larger than that. The rounding happens silently during JSON parsing. Either serialize the nanosecond value as a string, split it into a seconds field and a nanos field following the Protocol Buffers convention, or parse it as a BigInt on the client side. See RFC 7159 for the IEEE 754 caveats that JSON inherits.