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:

from datetime import datetime, timezone, timedelta EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) when = datetime(2026, 11, 2, 12, 0, tzinfo=timezone.utc) print(when.timestamp()) # 1793620800.0 print(int(when.timestamp())) # 1793620800 print(datetime.fromtimestamp(1_793_620_800, tz=timezone.utc)) # 2026-11-02 12:00:00+00:00 print(datetime.fromtimestamp(0, tz=timezone.utc)) # 1970-01-01 00:00:00+00:00 print(EPOCH + timedelta(seconds=-86400)) # 1969-12-31 00:00:00+00:00 print(EPOCH + timedelta(seconds=1_793_620_800)) # 2026-11-02 12:00:00+00:00

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:

ms = 1_793_620_800_123 print(datetime.fromtimestamp(ms / 1000, tz=timezone.utc)) # 2026-11-02 12:00:00.123000+00:00 try: datetime.fromtimestamp(ms, tz=timezone.utc) except (OverflowError, OSError, ValueError) as e: print("error:", type(e).__name__) # error: OSError (the type varies by platform)

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:

def to_seconds(v): a = abs(v) if a < 1e11: return v # seconds if a < 1e14: return v / 1e3 # milliseconds if a < 1e17: return v / 1e6 # microseconds return v / 1e9 # nanoseconds for v in (1_793_620_800, 1_793_620_800_123, 1_793_620_800_123_456, 1_793_620_800_123_456_789): print(v, "->", EPOCH + timedelta(seconds=to_seconds(v))) # 1793620800 -> 2026-11-02 12:00:00+00:00 # 1793620800123 -> 2026-11-02 12:00:00.123000+00:00 # 1793620800123456 -> 2026-11-02 12:00:00.123456+00:00 # 1793620800123456789 -> 2026-11-02 12:00:00.123457+00:00

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:

def to_us(dt): d = dt - EPOCH return (d.days * 86400 + d.seconds) * 1_000_000 + d.microseconds def from_us(us): return EPOCH + timedelta(microseconds=us) v = datetime(2026, 11, 2, 12, 0, 0, 123456, tzinfo=timezone.utc) print(to_us(v)) # 1793620800123456 print(from_us(to_us(v))) # 2026-11-02 12:00:00.123456+00:00 print(from_us(to_us(v)) == v) # True

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:

import struct print(2**31 - 1, EPOCH + timedelta(seconds=2**31 - 1)) # 2147483647 2038-01-19 03:14:07+00:00 print(EPOCH + timedelta(seconds=2**31)) # 2038-01-19 03:14:08+00:00 (what it should be) def wrap32(n): n &= 0xFFFFFFFF return n - 2**32 if n >= 2**31 else n print(wrap32(2**31), EPOCH + timedelta(seconds=wrap32(2**31))) # -2147483648 1901-12-13 20:45:52+00:00 (what it becomes) print(struct.pack("<I", 2**31).hex()) # 00000080 (the top bit is the sign bit) print(EPOCH + timedelta(seconds=2**32 - 1)) # 2106-02-07 06:28:15+00:00 (the unsigned limit)

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:

try: datetime(2016, 12, 31, 23, 59, 60) except ValueError as e: print(e) # second must be in 0..59, not 60 a = datetime(2016, 12, 31, tzinfo=timezone.utc) b = datetime(2017, 1, 1, tzinfo=timezone.utc) print((b - a).total_seconds()) # 86400.0 (the real day was 86401 seconds long) print(b.timestamp(), a.timestamp()) # 1483228800.0 1483142400.0 print(b.timestamp() % 86400) # 0.0 (midnight is always a multiple of 86400)

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.

QuestionAnswerWatch for
What is the epoch?00:00:00 UTC, 1 January 1970timestamps carry no zone; they are always UTC-based
Timestamp to datetimefromtimestamp(t, tz=timezone.utc)omitting tz gives local naive time
Naive datetime to timestampavoid; make it aware firstPython assumes local time
Seconds or milliseconds?count the digits: 10, 13, 16, 19a wrong unit gives a huge year or 1970
Exact storageinteger microsecondsfloats lose precision at nanosecond scale
Times before 1970negative valuesuse EPOCH + timedelta for portability
32-bit limit03:14:07 UTC, 19 January 2038use 64-bit fields for anything that stores future dates
Leap secondsnot counted; no :60a real day can be 86401 seconds, a Unix day never is

Challenges

Exercise 1

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.

๐Ÿ“„ View solution
Exercise 2

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.

๐Ÿ“„ View solution
Exercise 3

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.

๐Ÿ“„ View solution

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) or EPOCH + 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: datetime keeps 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
Coming next
Date and Time in Practice 9 looks at storing and exchanging dates: database column types, JSON, ISO 8601 strings and the rules for APIs.