Timestamps and Epochs
Date and Time in Practice
Chapter 8 ยท Timestamps and Epochs
A timestamp is a moment reduced to a single number: the count of time units since a fixed starting instant. Numbers are easy to store, sort and send between systems, which is why they are everywhere. They are also easy to misread, because the number alone does not say which unit it uses. This chapter covers the Unix epoch, the unit mix-ups that cause wrong dates, exact integer timestamps, the year 2038 limit, and what happens to leap seconds. Every snippet below was run with Python 3.14; the comments show what was printed.
The Unix Epoch
Unix time counts the seconds since 00:00:00 UTC on 1 January 1970, called the epoch. It is always relative to UTC, so a timestamp means the same instant everywhere and carries no time zone of its own. Converting needs an aware datetime, as Chapter 3 and Chapter 4 argued:
Always pass tz=timezone.utc to fromtimestamp. Without it you get a naive datetime in the machine's local zone, and the same number then prints differently on different servers. The reverse is just as important: calling .timestamp() on a naive datetime makes Python assume it is local time, so the result depends on where the code runs. An aware datetime has no such ambiguity.
Timestamps before the epoch are negative. Adding a timedelta to EPOCH handles them everywhere, whereas fromtimestamp with a negative value raises an error on some platforms (it did on the Windows machine used to test this chapter). The EPOCH + timedelta form is also the cleaner choice whenever a number comes from outside your program.
Seconds, Milliseconds and Beyond
Python and Unix tools use seconds, but many other systems use milliseconds (browsers, Java, many web APIs), microseconds (databases, some logs) or nanoseconds (high-resolution clocks). Feeding a millisecond value to a function that expects seconds does not give a slightly wrong date; it gives an enormous year:
The raw millisecond number is read as seconds, which lands roughly 57,000 years from now, past year 9999. The exception type differs between operating systems, so catch all three. A louder failure is the lucky case. If the wrong unit gives a date that is merely off (a stamp in seconds read as milliseconds is January 1970), nothing crashes and the bug hides.
The cure is to know the unit from the source, and to name variables with it (created_ms, expires_s). When you really must guess, the magnitude gives it away. A present-day stamp has 10 digits in seconds, 13 in milliseconds, 16 in microseconds and 19 in nanoseconds:
This is a heuristic for data cleaning, not a design. It works for dates between roughly 1973 and the year 5138 and breaks for tiny values near the epoch. Look closely at the last line: the nanosecond input came out as .123457, not .123456789. A datetime holds only microseconds, so nanoseconds are rounded away on the way in.
Exact Integer Timestamps
timestamp() returns a float. A 64-bit float carries about 15 to 16 significant digits, and a present-day stamp in microseconds already uses 16, with nanoseconds far beyond that. For ordinary work floats are fine, but for storage, comparison and equality checks, an integer count of a fixed unit is exact. Build it from the timedelta parts, which are already integers:
Python also offers time.time_ns(), an integer count of nanoseconds, for measuring the current time without float rounding. Use it, or time.monotonic() for elapsed-time measurement, since the wall clock can be adjusted backwards by a time-sync correction while your code runs. Reserve timestamps for "when did this happen" and monotonic clocks for "how long did it take".
The Year 2038 Problem
Many older systems store Unix time in a signed 32-bit integer. Its largest value is 231 - 1 = 2147483647, which is 03:14:07 UTC on 19 January 2038. One second later the stored value overflows and wraps to the most negative number, which reads as a date in 1901:
Python itself is not affected, because its integers never overflow, and modern operating systems use 64-bit time. The danger lies in anything that squeezes a timestamp into 32 bits: old database columns, file formats, embedded devices, network protocols. Dates in the future matter first: a 20-year loan, a certificate or a session expiry computed today can already pass 2038. An unsigned 32-bit field postpones the problem to 2106 but cannot hold dates before 1970. The real fix is a 64-bit field, which is good for roughly 292 billion years in seconds.
Leap Seconds
The Earth's rotation is slightly irregular, so civil time (UTC) occasionally inserts a leap second, written 23:59:60. The most recent one was at the end of 31 December 2016. Unix time ignores them: it pretends every day has exactly 86400 seconds, so a leap second is not counted and has no timestamp of its own. Python follows suit:
The practical effect is that Unix timestamps are not a true count of elapsed seconds; across a leap second they are one second short. For scheduling, logging and almost all business software that is exactly what you want, since a day always has 86400 seconds and midnight is always a whole multiple. Only precise scientific or satellite timing needs a time scale that counts every second, and that is not what datetime is for. Large providers often "smear" a leap second over many hours so that their clocks never show :60. If you receive a string containing 23:59:60 from an outside source, a strict parser such as Python's will reject it; clamp it to 23:59:59 before parsing if you must accept it.
| Question | Answer | Watch for |
|---|---|---|
| What is the epoch? | 00:00:00 UTC, 1 January 1970 | timestamps carry no zone; they are always UTC-based |
| Timestamp to datetime | fromtimestamp(t, tz=timezone.utc) | omitting tz gives local naive time |
| Naive datetime to timestamp | avoid; make it aware first | Python assumes local time |
| Seconds or milliseconds? | count the digits: 10, 13, 16, 19 | a wrong unit gives a huge year or 1970 |
| Exact storage | integer microseconds | floats lose precision at nanosecond scale |
| Times before 1970 | negative values | use EPOCH + timedelta for portability |
| 32-bit limit | 03:14:07 UTC, 19 January 2038 | use 64-bit fields for anything that stores future dates |
| Leap seconds | not counted; no :60 | a real day can be 86401 seconds, a Unix day never is |
Challenges
An export contains the values 1793620800, 1793620800500, 1793624400000, 1793706000250000, 0 and -86400, in mixed units. Write to_seconds and to_dt (using EPOCH + timedelta) and print each value as an ISO 8601 string. Then print the elapsed time between the second and third values and between the first and fourth. Predict each result by hand first, and say how you recognised each unit.
Write wrap32 to simulate a signed 32-bit integer. Print the last good second and the date it represents, the value and date it wraps to one second later, how long remains between 2026-11-02 12:00 UTC and the last good second (and the whole days), and the last second an unsigned 32-bit field could hold. Finish by printing wrap32 of six values from 2147483646 up to 4294967296. Predict the wrapped values first.
Write to_us and from_us. For four moments (2026-11-02 12:00:00.123456, 1969-07-20 20:17:40, 2000-01-01 and the epoch, all UTC) print the sorted integer microsecond values and whether each round-trips exactly. Split the first one with divmod into days since the epoch, seconds into the day and microseconds. Then show the leap-second day of 2016: the whole days and the seconds between 2016-12-31 and 2017-01-01, and the error raised when you build a time with second 60. Predict which values are negative.
Quick Reference
- Epoch: 00:00:00 UTC on 1 January 1970; a timestamp is a count of units since then and has no zone
- To datetime:
datetime.fromtimestamp(t, tz=timezone.utc)orEPOCH + timedelta(seconds=t) - From datetime: use an aware value;
.timestamp()on a naive one assumes local time - Units: 10 digits seconds, 13 milliseconds, 16 microseconds, 19 nanoseconds; name variables with the unit
- Precision:
datetimekeeps microseconds; store exact integer counts instead of floats - 2038: signed 32-bit seconds overflow at 03:14:07 UTC on 19 January 2038; use 64-bit
- Leap seconds: ignored by Unix time and rejected by
datetime; every Unix day is 86400 seconds - Elapsed time: use
time.monotonic(), not the wall clock