The universal clock of computing
A timestamp is not a clock. It is an address.
Read the address with one equation. Measure intervals with a second clock, the monotonic ruler. Learn to tell them apart and every famous time bug turns readable.
Unix time epoch explained, from one equation to two clocks
A timestamp is not a clock. It is an address on the UTC number line, fixed by one equation. A second clock, the monotonic ruler, measures intervals. Learn both and every famous time bug turns readable.
Unix time epoch explained: it is an address, not a clock#
Most explainers open with a definition and then link out to a converter. That teaches you to look up a value, never to reason about one. Instead, start with the reframe that makes everything else obvious. A timestamp does not tell the time. It names a position.
Think of one long number line measured in seconds. Zero sits at the epoch, midnight UTC on 1 January 1970. Every instant since then is a distance along that line. Therefore a timestamp is an address, and reading a clock face is just projecting that address into a local timezone. Because the address is a plain count of seconds, it carries no timezone at all. That single idea, unix time epoch explained as a position rather than a reading, is what the rest of this article keeps returning to.
The one equation everything hangs on#
The whole system rests on a single invariant that ranking pages rarely state. A Unix timestamp is whole days since the epoch, multiplied by the seconds in a day, plus the second within the current day.
One integer, three wall clocks#
Here is the address idea made concrete. Take one integer and render it on three different clock faces. The faces disagree on the label, yet they name the same instant. Change the number below and watch all three move together.
UTC
UTC +00:00
2023-11-14
22:13:20
Kyiv
UTC +02:00
2023-11-15
00:13:20
Kiribati (Kiritimati)
UTC +14:00
2023-11-15
12:13:20
Same integer, same instant. The three clocks disagree on the label, never on the address. That is why a timestamp needs no timezone to be exact.
Notice what did not happen. The integer never needed a timezone to be exact. Moreover the three faces are not three times. They are one address, drawn in three local dialects. That is why storing a UTC timestamp and rendering it locally is the correct default for a past instant.
Compute it by hand, both directions#
A converter hands you an answer and hides the work. So let us do the opposite and derive the value in both directions. This is unix time epoch explained the way a lookup tool never will, by hand. The panel below drives the arithmetic; flip the view to switch which way you are computing.
Result
Worked example: turn 1700000000 into a UTC date.
- days = floor(1700000000 / 86400)
19675 - second_of_day = 1700000000 - 19675 x 86400
80000 - clock = 80000 s split into h : m : s
22:13:20 - date = day 19675 counted from 1970-01-01
2023-11-14
Result22:13:20 UTC on 14 Nov 2023
Timestamp to date: 1700000000 step by step#
Take the real value 1700000000. First divide by the seconds in a day. That gives 1700000000 / 86400 = 19675 whole days with a remainder of 80000 seconds. Next split the remainder into a clock. Because 80000 seconds is 22 hours, 13 minutes, and 20 seconds, the time of day is 22:13:20. Then count 19675 days forward from 1970-01-01, which lands on 2023-11-14. Therefore the address resolves to 22:13:20 UTC on 14 November 2023. The same computation runs in every language.
// Timestamp -> UTC date, straight from the invariant.
const ts = 1700000000;
const days = Math.floor(ts / 86400); // 19675
const sod = ts - days * 86400; // 80000 (second_of_day)
const date = new Date(ts * 1000); // 22:13:20 UTC, 14 Nov 2023
console.log(days, sod, date.toISOString()); # The standard library already encodes the invariant.
import datetime as dt
ts = 1_700_000_000
days, sod = divmod(ts, 86_400) # (19675, 80000)
when = dt.datetime.fromtimestamp(ts, dt.timezone.utc)
print(days, sod, when.isoformat()) # 22:13:20 UTC, 14 Nov 2023 // time.Unix builds the instant; UTC() names it.
ts := int64(1700000000)
days, sod := ts/86400, ts%86400 // 19675, 80000
when := time.Unix(ts, 0).UTC() // 2023-11-14 22:13:20 +0000 UTC
fmt.Println(days, sod, when.Format(time.RFC3339)) // DateTime::from_timestamp runs the same arithmetic underneath.
let ts: i64 = 1_700_000_000;
let days = ts.div_euclid(86_400); // 19675
let sod = ts.rem_euclid(86_400); // 80000
let when = DateTime::from_timestamp(ts, 0).unwrap(); // 22:13:20 UTC, 14 Nov 2023
println!("{days} {sod} {}", when.to_rfc3339()); Date to timestamp, with sanity anchors#
The reverse direction runs the invariant forward. First count whole days from the epoch to your date. Then multiply by 86400 and add the seconds into the day. Two anchors let you check any value you derive. The epoch itself is unix second 0, which is midnight UTC on 1 January 1970, and midnight UTC on 18 July 2026 is unix second 1784332800. If your arithmetic reproduces both, trust it.
import datetime as dt
def to_unix(y, m, d, hh, mm, ss):
# whole days since the epoch, then apply the invariant
days = (dt.date(y, m, d) - dt.date(1970, 1, 1)).days
return days * 86_400 + hh * 3600 + mm * 60 + ss
# Two anchors let you self-check any value you derive.
assert to_unix(1970, 1, 1, 0, 0, 0) == 0
assert to_unix(2023, 11, 14, 22, 13, 20) == 1_700_000_000
assert to_unix(2026, 7, 18, 0, 0, 0) == 1_784_332_800 Why Unix time ignores leap seconds#
People ask why POSIX time ignores leap seconds, and the honest answer is not folklore. It falls straight out of the invariant. The definition of a Unix day forces it.
The 86,400-second day is a definition, not a measurement#
Astronomers occasionally add a leap second to UTC to keep clocks aligned with the Earth's slightly irregular spin. Unix time cannot represent that extra second. Because a Unix day is defined as exactly 86400 seconds, the counter has no slot for a 61st second. So it is not a faithful tally of physical seconds since 1970. It is a tally of defined days, and defined days never vary in length.
The leap second, second by second#
Watch the seam where a leap second is inserted. The clock is supposed to read three values in order. Unix time, however, has an integer for only two of them.
- 23:59:59
The last ordinary second
UTC and Unix time agree. The counter is about to tick over to the next day.
- 23:59:60
The inserted leap second
UTC adds a 61st second. Unix time has no integer for it, so this moment cannot be represented at all.
- 00:00:00
The repeated or stepped second
Unix time reuses the previous value or steps back. A wall-clock duration measured across the seam can come out negative.
STEP versus SMEAR: the failure mechanism#
The insertion is not just trivia. It is a real outage mechanism, and there are two ways to handle it. A STEP inserts or repeats a second, so a monotonic-looking wall-clock read can move backward. A SMEAR instead stretches each second near midnight, so the clock keeps moving forward and never repeats. The STEP path is what took down services in 2012 and 2015.
| Behavior | STEP: insert 23:59:60 | SMEAR: stretch the second |
|---|---|---|
| What happens | STEP: insert 23:59:60A 61st second is inserted; 23:59:59 repeats | SMEAR: stretch the secondEach second near midnight is made slightly longer |
| Clock still monotonic? | STEP: insert 23:59:60No: the same second can read twice | SMEAR: stretch the secondYes: the clock only moves forward |
| Failure mode | STEP: insert 23:59:60now - then < 0; duplicate timestamp-keyed rows | SMEAR: stretch the secondNo jump, but the clock is briefly off UTC |
| Who runs it | STEP: insert 23:59:60Default NTP and older kernels (2012, 2015) | SMEAR: stretch the secondGoogle, AWS, and Cloudflare public NTP |
The code makes the STEP failure concrete. When the same second reads twice, a naive duration goes negative and a guard clause fires.
# A leap second inserted as a STEP repeats 23:59:59, so a wall-clock read
# can move backward across the seam.
start = time.time() # ...T23:59:59.6
# leap second: the wall clock repeats the second
end = time.time() # ...T23:59:59.1 (earlier than start)
elapsed = end - start # negative: a duration that should never exist
if elapsed < 0:
raise RuntimeError("time went backward") # the 2012 and 2015 outage class Why it overflows: the Y2038 derivation#
The same invariant that hides leap seconds also predicts the Y2038 problem. The 32-bit overflow is not a mystery date to memorize. You can derive the exact boundary from the seconds in a day.
2,147,483,647 divided by 86400: land the boundary#
A signed 32-bit time_t can hold values up to INT32_MAX = 2147483647. Walk the division one step at a time.
Start from the largest signed 32-bit value
A signed 32-bit time_t maxes out at INT32_MAX = 2147483647 seconds.
Divide by the seconds in a day
2147483647 / 86400 = 24855 whole days, with a remainder of 11647 seconds.
Turn the remainder into a clock
11647 seconds is 03:14:07, that is three hours, fourteen minutes, and seven seconds.
Count the days from the epoch
Day 24855 counted from 1970-01-01 lands on 2038-01-19.
Read the boundary
The last representable second is 03:14:07 UTC on 19 January 2038. One tick later, the counter overflows.
The +1 tick that wraps 136 years back#
Now advance one second past that boundary. A signed counter cannot hold 2147483648, so it wraps to INT32_MIN = -2147483648. Feed that negative value back through the invariant and it resolves to 20:45:52 UTC on 13 December 1901. In one tick the clock jumps 136 years into the past. Snap to the boundary below and advance it yourself.
03:14:07 UTC on 19 Jan 2038- last safe second = INT32_MAX = 2147483647
03:14:07 UTC on 19 Jan 2038 - +1 overflows to INT32_MIN = -2147483648
20:45:52 UTC on 13 Dec 1901
One tick past the boundary, a signed counter jumps from 2038 back to 1901. That single wrap is the whole Y2038 problem.
Signed versus unsigned: 2038 versus 2106#
The sign bit is the whole story. Read the same 32 bits as unsigned and the ceiling moves. An unsigned counter has no negative half, so it keeps climbing for another 68 years before it too runs out.
2038-01-19
Signed 32-bit time_t
2106-02-07
Unsigned 32-bit counter
A signed counter dies at 03:14:07 UTC on 19 January 2038. Reading the same bits as unsigned survives to 06:28:15 UTC on 7 February 2106 (UINT32_MAX = 4294967295), because the sign bit becomes one more data bit.
Show data table
| Option | last representable instant (UTC) |
|---|---|
| Signed 32-bit time_t | 2038-01-19 |
| Unsigned 32-bit counter | 2106-02-07 |
Widening the type does not fix data already written#
The other clock: the monotonic ruler#
The address is only half the model. A second clock answers a different question. When you measure how long something took, you do not want an address at all. You want a ruler.
A ruler you measure intervals with, never an address#
A monotonic clock has a meaningless origin. It might count from the last boot or from some arbitrary point. Only the difference between two reads carries meaning. Crucially, it never jumps backward. The wall clock, by contrast, is an address that can be reset, stepped by NTP, folded by DST, or restored from a VM snapshot. So the two clocks answer two questions, and the diagram below keeps them apart.
Watch a wall clock go backward#
This is the bug the SERP mentions in one line and never shows. Measure an interval with a wall clock, then let the clock get corrected mid-measurement. The naive elapsed, end - start, can go negative. Start a measurement below, inject a fault, then stop and compare the two clocks.
Wall clock: an ADDRESS
00:00:00
Date.now() / time.time(). Can jump when the clock is set.
Monotonic clock: a RULER
0.00 s
performance.now() / CLOCK_MONOTONIC. Only ever counts up.
Press Start, inject a fault, then Stop to compare the two measurements.
A request starts, and four seconds of real work later it ends. During those four seconds NTP steps the wall clock back seven seconds:
- Wall clock says: end - start =
-3.0 s(a negative duration). - Monotonic ruler says: end - start =
+4.2 s(the honest interval).
The wall clock is an address and an address can move. Measure an interval with it and a backward step makes the duration negative. The monotonic ruler has no address to move, so it cannot lie about the interval.
The monotonic ruler stayed honest through every fault. Therefore the rule is simple. Timestamp events with the wall clock, and measure durations with the monotonic clock. Mixing them is how a backoff underflows and a timeout check inverts.
Monotonic in Go, Rust, and Linux#
Every runtime exposes the ruler, though the names differ. In Go the monotonic reading rides along inside time.Now(). In Rust it is Instant::now(). On Linux it is clock_gettime(CLOCK_MONOTONIC). Here is the same measurement in all three.
// time.Now() carries a monotonic reading; subtraction uses it automatically.
start := time.Now()
doWork()
elapsed := time.Since(start) // immune to wall-clock jumps // Instant is monotonic by construction and has no calendar meaning.
let start = Instant::now();
do_work();
let elapsed = start.elapsed(); // never negative /* CLOCK_MONOTONIC never jumps; CLOCK_REALTIME (the wall clock) can. */
struct timespec a, b;
clock_gettime(CLOCK_MONOTONIC, &a);
do_work();
clock_gettime(CLOCK_MONOTONIC, &b);
double s = (b.tv_sec - a.tv_sec) + (b.tv_nsec - a.tv_nsec) / 1e9; Address or ruler? Classify every task#
The model becomes a tool the moment you classify a job before writing code. That is unix time epoch explained as a decision procedure, not a lookup. So ask one question first. Are you naming an instant, or measuring an interval? The answer picks the clock, the storage type, and the timezone handling.
The decision flow#
Walk the branches in order. Measuring an interval sends you to the ruler. Naming a past instant sends you to the wall-clock address. A future local event needs a third path entirely, which the next section explains.
Task to clock, storage type, precision, timezone#
The same branches fit into a lookup table keyed by the actual job. Keep it close when you design a schema.
| Task | Clock | Store as | Precision | Timezone |
|---|---|---|---|---|
| Timestamp an event | ClockWall (address) | Store asUTC seconds or RFC 3339 | PrecisionSeconds or ms | TimezoneUTC, render local |
| Measure latency or backoff | ClockMonotonic (ruler) | Store asNothing; keep a delta | PrecisionNanoseconds | TimezoneNone |
| Schedule a future deadline | ClockWall (address) | Store asLocal wall-time + IANA zone | PrecisionMinutes | TimezoneIANA zone id |
| Order events across nodes | ClockWall + logical | Store asUTC plus a logical counter | PrecisionMs + sequence | TimezoneUTC |
| Store a birthday | ClockCalendar | Store asA date, no time of day | PrecisionDay | TimezoneNone |
| Store a recurring meeting | ClockWall (address) | Store asLocal time + rule + zone | PrecisionMinutes | TimezoneIANA zone id |
The future-timestamp storage trap#
Here is the case every ranking page gets wrong. The advice to store UTC and display local is correct only for a past instant. A future local event is different. Because a government can move a DST boundary before the date arrives, freezing UTC now bakes in a rule that might change. So a deadline or a recurring meeting must be stored as local wall-time plus an IANA zone, then resolved to UTC at read time.
-- WRONG for a future local event: freezing UTC now bakes in today's rule.
-- If the government later moves the DST boundary, the meeting shifts an hour.
INSERT INTO meetings (starts_at_utc) VALUES ('2027-03-15 08:00:00');
-- RIGHT: store the local wall-time plus the zone, resolve to UTC at read time.
INSERT INTO meetings (local_time, iana_zone)
VALUES ('2027-03-15 09:00:00', 'Europe/Kyiv'); Every famous bug, decoded by the model#
Now the model pays off. Each famous class of time bug reduces to one of two faults. Either the address broke an invariant, or someone used the address as a ruler. Read the catalogue and the postmortems stop being war stories and start being predictions.
| Bug class | What broke | Which rule it violates |
|---|---|---|
| Seconds vs milliseconds | What brokeA timestamp read 1000x too large or too small | Which rule it violatesWrong unit on the address |
| Y2038 overflow | What brokeA signed 32-bit counter wrapped to 1901 | Which rule it violatesThe address outgrew its integer |
| Leap-second outage (2012, 2015) | What brokenow - then < 0 crashed running daemons | Which rule it violatesMeasured an interval with the address |
| DST fold | What brokeOne local time named two real instants | Which rule it violatesAmbiguous address, no zone stored |
| Negative duration | What brokeA backoff underflowed after a clock set | Which rule it violatesUsed the address as a ruler |
| Future-deadline breakage | What brokeA meeting moved when a DST rule changed | Which rule it violatesFroze UTC for a future local event |
When NOT to reach for Unix time#
The address is powerful, yet it is the wrong tool for several common jobs. A Unix timestamp is a point on the UTC line, so anything that is not such a point does not belong in one.
Reference: storage types and the epoch zoo#
Two tables are worth returning to. The first maps database column types to the ranges they cover. The second lists the day-zero of five different systems, because Unix is not the only epoch you will meet.
| Type | Range | Zone-aware? | Use it for |
|---|---|---|---|
| MySQL TIMESTAMP | Range1970 to 2038 (signed 32-bit) | Zone-aware?Converts to the session zone | Use it forInstants inside the range |
| MySQL DATETIME | Range1000 to 9999 | Zone-aware?No zone stored | Use it forWall-time you store literally |
| Postgres TIMESTAMPTZ | RangeMicrosecond, very wide range | Zone-aware?Stores UTC, renders local | Use it forAlmost every instant |
| Postgres TIMESTAMP | RangeMicrosecond, very wide range | Zone-aware?No zone stored | Use it forLocal wall-time, resolve later |
| Unix seconds (int64) | RangeAbout 292 billion years | Zone-aware?None; always UTC | Use it forPortable event timestamps |
| System | Epoch (day zero) | Tick size | Notes |
|---|---|---|---|
| Unix / POSIX | Epoch (day zero)1970-01-01 UTC | Tick size1 second | NotesThe epoch this article derives |
| Windows FILETIME | Epoch (day zero)1601-01-01 UTC | Tick size100 nanoseconds | NotesStart of the Gregorian cycle |
| NTP | Epoch (day zero)1900-01-01 UTC | Tick sizeabout 232 picoseconds | NotesIts own 32-bit era rolls over in 2036 |
| Apple / Cocoa | Epoch (day zero)2001-01-01 UTC | Tick size1 second | NotesThe NSDate reference date |
| GPS | Epoch (day zero)1980-01-06 UTC | Tick size1 second | NotesNo leap seconds, so now ahead of UTC |
Sources#
Every value here was computed against primary specifications, not secondary blogs. The seconds-since-the-epoch formula and the 86400-second day come from the POSIX base definitions, chapter 4 (opens in new tab). Clock resolution and the monotonic clock identifiers are defined in the POSIX clock_getres and clock_gettime page (opens in new tab). The authoritative record of every inserted leap second is the IANA leap-seconds.list (opens in new tab). In 2022 the CGPM voted to stop inserting leap seconds by 2035, recorded in BIPM CGPM 2022, resolution 4 (opens in new tab). The timestamp string format used throughout is RFC 3339 (opens in new tab).
This piece sits beside our other deep dives into what the machine actually does. If you liked deriving the numbers here, our walkthrough of how computers generate randomness takes the same first-principles approach to entropy. Because the monotonic clock is really a hardware timer, our explainer on why CPU cache outperforms RAM is the companion read on where those cycles come from. And since every HTTP response carries a wall-clock date and a TLS validity window, what happens when you hit a URL shows these timestamps in flight. For the full set, browse more software engineering deep-dives on the Atyantik technical blog.
Time bugs are quiet until they are a 3 a.m. incident, and they usually surface as a negative duration or a certificate that reads as not-yet-valid. If you want a second set of hands on a timezone migration, a Y2038 audit of serialized data, or an event schema that will not fold under DST, we are glad to help. No pressure and no lock-in.
Talk to our software engineering team