Forget Everything You Know About Time
M itch Hedberg had a bit about late-night television:
“I saw a commercial on late-night TV, it said, ‘Forget everything you know about slipcovers.’ So I did, and it was a load off my mind. Then the commercial tried to sell me slipcovers, but I didn’t know what the hell they were.”
So here is my pitch: forget everything you know about time.
I mean it. Put the intuition down and step away from it. You learned clocks and calendars before you could read, you have used them every single day since, and that fluency is precisely the problem — it is conversational fluency. It survives on ambiguity, because a human listener quietly patches the gaps for you. A computer will not. It takes your assumption, applies it with total consistency, and hands you back something wrong in a way you never thought to check.
So let’s throw it out and build it back together, from an empty page, one piece at a time. It takes about ten minutes and it is worth every one of them, because the whole thing collapses into a single idea:
There are two different things in your codebase, both called “time.” Almost every date bug you will ever write is one of them being mistaken for the other.
(There is a third. It only appears once you have more than one machine, and it is the last section.)
Realm One: Physical Time#
Picture a line. It has no numbers on it, no labels, no origin. Things happen on it, in order. That is the whole model.
you are here
│
◄───────────────────────────────┼───────────────────────────────►
│
past now future
A single point on that line is an instant. A server received a request. A row was committed. A packet arrived. Instants are objective — they do not belong to anyone, they are not in a country, and they have no opinion about what day it is.
The gap between two instants is a duration, and a duration is measured in seconds. Not in days, not in months — seconds. Everything else is a multiple of one.
◄──────┼──────────────────────────────┼──────────────────────────►
request response
accepted written
│ │
└────────── 1.372 s ───────────┘
a duration
Instants and durations look similar and are not the same kind of thing at all. A duration is a quantity — you can double it, halve it, average a thousand of them. An instant is a position — it has no magnitude, and the arithmetic reflects that:
instant − instant = duration ✓
instant ± duration = instant ✓
duration ± duration = duration ✓
duration × number = duration ✓
instant + instant = ??? ✗ meaningless
Adding two instants is as sensible as adding two street addresses. If your code does it, something upstream is already wrong.
Here is the interesting part. There is no way to name a single instant. You cannot say “instant number 4” any more than you can point at a spot in an empty field and give it a number. What you can do is pick one instant, agree with everyone that it is the reference, and then describe every other instant as a duration from it:
THE EPOCH the instant
(an arbitrary mark we care about
everyone agreed on) │
│ │
◄─────────────────┼────────────────────────────────────┼────────►
│ │
└────────── 1,785,000,000 s ─────────┘
a duration
"the instant 1785000000" is shorthand for
"the instant one duration after the mark"
That agreed-on reference is called an epoch. The one nearly everything you have ever written uses is the Unix epoch: the instant that the civil datetime 1970-01-01 00:00:00 maps to in UTC. Pick that as your mark, count the seconds since, and you have 1785000000 — a name for an instant.
That is the whole trick, and it explains a familiar smell: when a value goes missing or arrives as zero, it renders as 1970-01-01. You have seen that date in a bug report. Now you know it is not a date the system chose — it is the count being zero, the mark itself showing through.
The Unix epoch is not special. Nobody discovered it; someone picked it. And most of your code should never need to know what it is — that is your library’s business, and if you could swap it out tomorrow without anything breaking, that is a sign you have been storing instants rather than integers.
Note also what just happened: the only way we could name a position was to borrow a quantity.
And a timestamp is…?#
We use the word constantly, so let’s pin it down while we are still in the simple realm. Four words that go together:
"hey, what time is it?"
the thing you ask ............ a CLOCK
what it reads ................ WALL TIME
the answer it gives you ...... an INSTANT
that instant, once recorded .. a TIMESTAMP
That is all a timestamp is: an instant, usually one that was read off a clock. It is not a special type and it carries no extra powers. Two properties are worth holding onto, though.
First, a timestamp is stale the moment you have it. By the time the value is in your variable it names a point in the past, which is fine and expected — it does not stop being a timestamp.
Second, and this is the one that bites: a timestamp is an approximation. The clock that produced it is not authoritative, it drifts, and it gets corrected. So subtracting two of them is a shaky way to measure anything. We will come back to that at the end, because it is the single most common way this realm gets misused.
Physical time is otherwise a small world. It has now, before, after, and how long. It has no Tuesdays, no January, no midnight, no “end of the quarter.” For a surprising amount of backend work that is everything you need — and code that stays inside this realm is nearly impossible to get wrong.
Realm Two: Civil Time#
Then there is the time we actually talk in.
Civil time is a stack of human conventions sitting on top of the line: years, months, leap days, weekends, business hours. In the Gregorian calendar, a datetime is six fields, and that is all it is:
┌──────┬───────┬─────┬──────┬────────┬────────┐
│ year │ month │ day │ hour │ minute │ second │
└──────┴───────┴─────┴──────┴────────┴────────┘
2026 08 02 14 30 00
Notice what is not in that box: a time zone. This trips people up, so it is worth stating flatly — a time zone is not the seventh field of a datetime. It is not part of civil time at all. “Let’s meet at 2:30 on Tuesday” is a complete, useful, unambiguous sentence between two people in the same room, and no time zone appears anywhere in it.
The civil analogue of a duration is a period: “3 months”, “2 weeks”, “one year”. And here the wheels come off, because a period is not a quantity of anything. It is an instruction for how to move around the calendar, and those instructions are ill-defined.
What is October 31st plus one month?
There is no correct answer. There is only whatever your library picked. Go picked this one, and documents it plainly:
oct31 := time.Date(2026, time.October, 31, 0, 0, 0, 0, time.UTC)
oct31.AddDate(0, 1, 0)
// 2026-12-01
November has no 31st, so Go normalizes “November 31” into December 1st. You added one month and landed two months out. That is not a bug in Go — it is the honest consequence of asking a question that has no answer.
It gets worse. Period arithmetic is not associative:
(Jan 31 + 1 month) + 1 month
└─ Feb 31, normalized to Mar 3 ─┘ + 1 month → Apr 3
Jan 31 + (1 month + 1 month)
└───────── Jan 31 + 2 months ───────────────────┘ → Mar 31
Same inputs, different grouping, different answer. You lost a law of arithmetic you have been relying on since primary school and nobody sent a memo.
The Bridge: Time Zones#
Two realms, then. Physical time models reality; civil time models the conversation. Both are useful and they are not interchangeable — so we need something to convert between them.
That is all a time zone is. Not a field, not a place, not an offset — a ruleset for converting an instant into a datetime and back.
Come to think of it, we all ran this conversion long before we could name it. When I was young you did not check a phone to know the time — you looked up. Sun climbing, still morning. Sun directly overhead, that is midday, go and eat. Sun leaning west and the shadows getting long, evening is coming and you should already be heading home.
That is a time zone. Genuinely, structurally, that is the whole job: take something physical — the position of the sun, an instant on the line — and turn it into a civil label a human can act on. “Midday” was the output.
And it worked beautifully, right up until it had to scale. Because your midday and the midday of a village a hundred kilometres east are not the same instant. Every longitude has its own noon. Nobody minded while the fastest thing on the road was a person walking; the moment we had railways and telegraphs and, eventually, an API call crossing four countries in 80 milliseconds, we had to agree to lie a little. Everyone inside a region would pretend to share one noon. That agreement is the modern time zone, and every messy thing about it descends from the fact that it is a convenient fiction laid over a continuous physical reality.
Not everything called a time zone is any good at being one#
Which brings up the uncomfortable part: time zones vary wildly in quality, and several of the things you routinely call a time zone are, frankly, bad at the job.
There is a test for this. A time zone’s entire purpose is answering conversion questions, so a good one answers all of them — any instant to a datetime, and nearly any datetime back to an instant, for any date in the past or the plausible future. That is a high bar, and it cannot be cleared by arithmetic, because the rules are not mathematical. They are legislative. Somebody’s parliament decides, sometimes with a few weeks’ notice, and the conversion has to know.
Which means a real time zone is not a formula at all. It is a maintained historical record — every rule change a jurisdiction has ever made, kept current by people who follow this for a living. That record is the IANA time zone database, and its entries are the ones with a region and a slash:
✓ GOOD AT BEING A TIME ZONE ✗ BAD AT BEING A TIME ZONE
Africa/Nairobi EAT
America/New_York EST
Asia/Kolkata PST
Pacific/Auckland +03:00
a full rule history, so it can a label or a fixed number.
answer any conversion you ask, answers "what is the offset
including ones from 1994 and right now, roughly", and
ones from next April nothing else
The right-hand column fails the test in two different ways. A bare offset like +03:00 is a snapshot of one answer, not the rules that produce answers — it cannot tell you what the offset will be in November, so it cannot convert a future datetime. And the three-letter codes are worse than useless because they are ambiguous: the software world never agreed on what they mean, so some of them track daylight saving and some are frozen. A code that resolves to two different instants depending on which library parsed it has no business in a database column.
Store Africa/Nairobi. Never EAT. The name is not a formatting preference — it is the key into the record that makes conversion possible at all.
The bridge has holes#
The instant → datetime direction always works. Every instant has a wall-clock reading in every zone. Fine.
The datetime → instant direction does not always work, and this is where the bodies are buried. Twice a year, in any zone that observes daylight saving, the mapping breaks:
SPRING FORWARD — America/New_York, 2026-03-08
01:00 ──── 01:59 ██████████████ 03:00 ──── 03:59
│ │
└── 02:00 to 02:59 ──┘
this hour does not exist
02:30 is not a time that happened
FALL BACK — America/New_York, 2026-11-01
01:00 ──── 01:59 ──── 01:00 ──── 01:59 ──── 02:00
└── EDT ────────┘ └── EST ────────┘
01:30 happens twice
which one did the user mean?
So the bridge is not a clean one-to-one. Going one way it is total; coming back it is not a function at all:
2026-03-08"]) -->|"maps to"| N(["NOTHING
0 instants"]) DT2(["14:00 on
2026-08-02"]) -->|"maps to"| ONE(["exactly
1 instant"]) DT3(["01:30 on
2026-11-01"]) -->|"maps to"| TWO(["TWO instants
an hour apart"])
Your libraries will not raise an error for the first or the last case. They will guess.
Python guesses quietly:
from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
# a wall clock that never existed — no exception, no warning
never = datetime(2026, 3, 8, 2, 30, tzinfo=ny)
# a wall clock that happened twice — 'fold' picks which one
first = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=0) # EDT, the first pass
second = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=1) # EST, an hour later
That fold flag is the only thing standing between you and a coin toss, and I have never once seen it set in application code.
Go guesses too. time.Date for a skipped wall clock hands back a nearby instant rather than an error — the docs are upfront that in these cases the result “is not guaranteed.”
Africa/Nairobi is UTC+3 all year and always has been. Your laptop will never reproduce a daylight saving bug. Not once. Your users in São Paulo and Auckland will find them for you, in production, twice a year.UTC Is Not an Escape Hatch#
The received wisdom is “just use UTC everywhere.” That is directionally right and subtly incomplete.
UTC is a legitimate time zone — the simplest one possible, offset permanently zero, no daylight saving, no legislature. It gives you a clean bijection between instants and datetimes, which makes it the perfect reference for describing every other offset. It is genuinely good.
But it is still a time zone, and that means it is still a bridge into civil time. Nothing stops you from walking across it:
# you have "gone UTC", so this is safe, right?
if timestamp.weekday() == 3:
run_thursday_batch()
Whose Thursday? That question has an answer for a user in Nairobi and a different answer for a user in Los Angeles, and by asking it you have quietly become zone-dependent while believing you were not.
Zone independence does not come from choosing UTC. It comes from staying in physical time and never crossing over — using types that model instants and durations and cannot answer a question about weekdays. If the type cannot express the bug, you cannot write it.
Both Languages Hand You a Loaded Gun#
Now the uncomfortable part: Go and Python both give you a single type that lives in both realms at once.
Go’s time.Time is an instant plus a *time.Location. Python’s aware datetime is six fields plus a tzinfo. Each one is a value with a foot in both realms — it will answer physical questions and civil questions with the same syntax, and they mean different things.
go: time.Time python: datetime (aware)
┌────────────────────────┐ ┌────────────────────────┐
│ wall clock │ │ Y M D h m s µs │ ← civil
│ monotonic reading │ ├────────────────────────┤
├────────────────────────┤ │ tzinfo │ ← the bridge
│ *Location │ └────────────────────────┘
└────────────────────────┘
▲ ▲
└── one value, both realms ────────┘
every method you call is quietly picking one
The type will not stop you. It cannot — it holds enough information to serve either interpretation, so the choice falls to whichever method name you happened to type.
Watch. Same starting moment, same “add one day”, two different answers:
loc, _ := time.LoadLocation("America/New_York")
t := time.Date(2026, time.March, 7, 12, 0, 0, 0, loc)
t.Add(24 * time.Hour) // 2026-03-08 13:00 EDT ← physical: exactly 86,400s
t.AddDate(0, 0, 1) // 2026-03-08 12:00 EDT ← civil: same wall clock, 23 real hours
Go at least makes you pick: Add takes a Duration and is physical, AddDate takes calendar units and is civil. Two methods, two realms, no ambiguity about which one you asked for.
Python has one operator, and it silently chose civil for you:
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
t = datetime(2026, 3, 7, 12, 0, tzinfo=ny)
t + timedelta(days=1)
# 2026-03-08 12:00 EDT — wall-clock arithmetic. Only 23 hours actually elapsed.
(t.astimezone(timezone.utc) + timedelta(days=1)).astimezone(ny)
# 2026-03-08 13:00 EDT — 24 hours actually elapsed.
timedelta is named like a duration and behaves like a period the moment there is a tzinfo attached. If you want physical arithmetic in Python, convert to UTC, do the math, convert back. Every time.
Both languages, same picture — “one day later” lands in two different places, and the DST boundary is what pries them apart:
the clocks jump forward here
▼
Mar 7 ░░░░░│░░░░░ Mar 8
12:00 EST │ 12:00 13:00
│ │ EDT EDT
●───────────────────────┼─────────────────────►│ │
│ │ 23 real hours │ │
│ │ CIVIL │ │
│ │ "same wall clock │ │
│ │ tomorrow" │ │
│ │ │
●───────────────────────┼────────────────────────────────►│
│ 24 real hours
│ PHYSICAL
│ "86,400 seconds later"
Go: AddDate(0,0,1) Add(24 * time.Hour)
Python: dt + timedelta(days=1) via UTC round-trip
Neither answer is wrong. They answer different questions. The bug is not picking one.
Two more traps worth knowing#
Python’s naive/aware split. A naive datetime has no tzinfo and is therefore not an instant — it is a wall clock with no address. The standard library will let you build one by accident in three different ways:
datetime.now() # naive. local zone, unrecorded.
datetime.utcnow() # naive — and deprecated since 3.12. It is UTC
# by convention only; nothing in the value says so.
datetime.now(timezone.utc) # aware. an actual instant. use this one.
utcnow() is the worst of the three precisely because it looks the most correct. It returns a value that is UTC and does not know it is UTC, so the first thing that reads it applies the local zone and you are off by your offset.
Go’s ==. time.Time is a struct, so == compiles fine and compares the wall clock, the monotonic reading, and the location pointer. Two values naming the same instant in different zones are not equal. Use Equal:
t1.Equal(t2) // ✓ compares the instant
t1 == t2 // ✗ compares the representation
What To Store#
You have an instant and you have a wall clock. Which one goes in the column?
It depends on which one is the fact, and the two pull in opposite directions:
"the payment settled" → store the INSTANT
a thing that happened. it is pinned to reality forever.
when the zone rules change, the wall clock it renders as
the fact does not move. can be recomputed anytime.
"rent is due on the 1st → store the CIVIL DATETIME + the zone
at 09:00, Nairobi" a promise about a wall clock.
a thing that will happen. if the government moves the clocks,
the promise moves with them —
that is what the user meant.
Get this backwards and you get the classic failure: a government shifts a daylight saving boundary, and every future appointment in your database silently slides by an hour. The events were stored as instants when they were really promises about wall clocks.
Ask which one your user would want preserved if the rules changed underneath them. That is the one you store.
Wall Clock vs. Monotonic Clock#
Last piece, and it is the one that cost me a week — see Bug #1.
The clock that tells you what time it is now is a guess. It drifts, and a daemon periodically corrects it against a network source. Those corrections are jumps. They can be backwards.
So this is wrong:
start = datetime.now(timezone.utc)
do_work()
elapsed = datetime.now(timezone.utc) - start # ✗ can be negative
Two approximate readings subtracted from each other give you a result carrying both of their errors, and if a correction landed in the middle you get nonsense:
an NTP correction lands here
▼
WALL CLOCK 10:00:00 ─── 10:00:07 ──┐
what time │ ← yanked backwards 5s
is it ┌──────────────────┘
└─ 10:00:02 ─── 10:00:04
│ │
start end reported: 4 s
actual: 9 s
MONOTONIC 0.000 ─────────────────────── 9.113
how long │ │
since some start end reported: 9.113 s
arbitrary point actual: 9.113 s
Use a monotonic clock — one that only ever counts forward and makes no claim about what time it is:
import time
start = time.monotonic()
do_work()
elapsed = time.monotonic() - start # ✓ seconds, always forward
Go builds this in. time.Now() carries a hidden monotonic reading, and time.Since uses it automatically:
start := time.Now()
doWork()
elapsed := time.Since(start) // ✓ monotonic, unaffected by clock corrections
One catch: the monotonic reading is stripped when a time.Time is serialized or rounded. A start time that made a round trip through JSON is back to being wall time, and your measurement is back to being a guess.
Realm Three: Logical Time#
Everything up to here quietly assumed one machine.
Drop that assumption and something breaks that neither realm can fix. Because the question you actually need answered in a distributed system is almost never “what time is it” — it is “which of these two things happened first?” And a timestamp cannot tell you.
First, how wrong is the clock?#
A computer’s clock is a quartz oscillator, and quartz drifts. Tens of parts per million is ordinary, which works out to several seconds a day if nothing intervenes. Something does intervene — NTP, checking against an upstream source and dragging the clock back into line, either by slewing it (running it slightly fast or slow until it catches up) or by stepping it (a hard jump, which is what mangles your elapsed-time measurements).
What NTP does not do is make the clock correct. It makes it correct within a margin: single-digit to tens of milliseconds over the public internet on a good day, better on a well-run LAN, worse when a path is congested. That residual error is not noise you can average away. It is a hard floor on what your timestamps can prove.
Now put two servers side by side:
NODE A true time 10:00:00.000 clock reads 10:00:00.030 (+30ms)
NODE B true time 10:00:00.000 clock reads 09:59:59.985 (−15ms)
└─ 45ms apart ─┘
── what actually happened ─────────────────────────────────────────────
A: write "balance = 100" ● true 10:00:00.100
B: read balance, write "= 50" ● true 10:00:00.120 (caused by A)
── what the timestamps say ────────────────────────────────────────────
A's write stamped 10:00:00.130
B's write stamped 10:00:00.105 ← earlier. by the log, B came first.
the cause is now recorded as happening after the effect
Nothing malfunctioned. Both clocks were inside a perfectly normal NTP margin. The events were 20 ms apart and the clocks disagreed by 45 ms, so the ordering inverted — and no amount of sorting your log by timestamp will unpick it.
And monotonic clocks are no rescue here. A monotonic reading counts from an arbitrary origin on that machine — usually boot. Comparing one machine’s time.monotonic() against another’s is meaningless.
Lamport’s move: stop asking “when”#
In 1978 Leslie Lamport published the paper that resolved this, and the insight is a genuine reframe: give up on knowing when things happened, and track only what could have caused what.
Formally that is the happens-before relation, written a → b, and it holds in exactly three cases: a and b happened on the same node with a first; a was a message send and b was its receive; or there is a chain of those linking them. If none of that applies, the events are concurrent — not simultaneous, just causally unrelated. Nothing connects them, so no order between them is more correct than any other.
A Lamport clock tracks this with one integer per node and three rules:
local event → c = c + 1
before sending → c = c + 1, attach c to the message
on receiving → c = max(c, received) + 1
That is the whole algorithm. Here it is running across three nodes:
e1 e2
P1 ────①─────────────②──────────────────────────────────────►
╲
╲ m1 carries 2
↘
e3 e4 e5 e6
P2 ─────①─────────────────③─────────④─────────⑤─────────────►
max(1,2)+1 ╲
╲ m2 carries 5
↘
e7 e8 e9
P3 ─────①─────────②───────────────────────────────⑥─────────►
max(2,5)+1
The guarantee: if a → b then c(a) < c(b). Causality never runs backwards in the numbers. That is enough to build a great many things.
But read the guarantee carefully, because the converse is false. c(e8) = 2 and c(e1) = 1, so e1 has the smaller number — yet nothing connects them. They are concurrent, and the clock cannot say so. A Lamport clock compresses “before” and “unrelated” into the same answer, and once compressed you cannot get them back.
Vector clocks: paying for the missing answer#
Sometimes “unrelated” is exactly what you need to know. Two replicas both accepted a write to the same key — is one an update of the other, or did they happen independently and you now have a genuine conflict?
A vector clock answers that by keeping a counter per node instead of one:
local event → V[me] += 1
before sending → V[me] += 1, attach the whole vector
on receiving → V = elementwise max(V, received), then V[me] += 1
Now comparison has three outcomes instead of two:
A = {n1:2, n2:1, n3:0} B = {n1:3, n2:1, n3:0}
every component A ≤ B, at least one strictly less → A happened before B
A = {n1:2, n2:1, n3:0} B = {n1:1, n2:4, n3:0}
A leads on n1, B leads on n2, neither dominates → CONCURRENT
a real conflict
In Python that is about six lines:
def compare(a: dict[str, int], b: dict[str, int]) -> str:
nodes = a.keys() | b.keys()
a_le = all(a.get(n, 0) <= b.get(n, 0) for n in nodes)
b_le = all(b.get(n, 0) <= a.get(n, 0) for n in nodes)
if a_le and b_le: return "identical"
if a_le: return "a → b"
if b_le: return "b → a"
return "concurrent" # ← the answer no wall clock can ever give
This is how Dynamo-lineage stores decide whether to merge silently or hand you both versions and make you choose. The price is in the first line: a timestamp is now O(N) in the number of nodes, it grows as the cluster grows, and it needs a story for nodes joining and leaving. Fine for a handful of replicas. Not fine for a thousand.
Hybrid logical clocks: the practical compromise#
So: physical timestamps are readable and comparable across the whole system but can lie about order. Logical clocks never lie about order but are disconnected from real time — a Lamport counter of 4,182 tells you nothing about when, and you cannot query a range with it or hand it to an operator.
A hybrid logical clock takes both. Each timestamp is a pair — a physical component and a logical counter:
┌──────────────────────────┬─────────────┐
│ physical part │ counter │
│ tracks the wall clock, │ breaks ties │
│ never runs behind it │ when the │
│ by more than the skew │ physical │
│ │ part stalls │
└──────────────────────────┴─────────────┘
on any event: take max(my physical part, my wall clock, anything
arriving on a message); if that value did not
advance, bump the counter instead
What you get is a value that respects causality the way Lamport does, stays within known skew of real wall time so it is still meaningful to a human and still range-queryable, and — unlike a vector clock — never grows. Constant size, forever.
That combination is why HLCs ended up under CockroachDB and MongoDB’s causal consistency rather than either pure approach.
The other way out: admit the uncertainty#
There is one more option, and it is the opposite philosophy. Instead of routing around bad clocks, buy good ones and be honest about the error bar.
Google’s Spanner does this. GPS receivers and atomic clocks in every datacentre get the skew down to a few milliseconds, and then — the actual clever part — the API refuses to return a single instant. It returns an interval: earliest and latest, guaranteed to contain the true time. If two intervals overlap, the system does not know the order, and it says so.
Then it does the thing that sounds absurd and works: before committing, it waits for the uncertainty window to elapse. A few milliseconds of deliberate sleep, so that by the time the commit is visible, its timestamp is unambiguously in the past for every other node. Latency spent to buy a real ordering guarantee.
Most of us do not have atomic clocks in the rack. But the design lesson transfers, and it is free: when you record a time you cannot fully trust, record what you know about the trust too.
Choosing between them#
size spots tracks
concurrency? real time?
─────────────────────────────────────────────────────────────
physical timestamp O(1) ✗ ✓✓
Lamport clock O(1) ✗ ✗
vector clock O(nodes) ✓ ✗
hybrid logical clock O(1) ✗ ✓
TrueTime-style interval O(1) ✓ (explicit) ✓✓
For most of what you will build, the honest answer is that you do not need any of this — one service, one database, and the database’s own ordering is the source of truth. Reach for logical time when you have genuinely concurrent writers and need to know whether two versions conflict, and reach for it knowing that a physical timestamp was never going to tell you.
The Cheat Sheet#
Before the table, the question that picks the row. Ask it before you declare the field, not after the bug:
actually storing?"} Q -->|"how long
something took"| MONO(["MONOTONIC CLOCK
time.Since · time.monotonic()"]) Q -->|"a thing that
happened"| INST(["INSTANT
time.Time · aware datetime"]) Q -->|"a promise about
a wall clock"| CIVIL(["CIVIL DATETIME + ZONE
civil.DateTime · naive datetime"]) Q -->|"a calendar day,
no instant behind it"| DATE(["DATE
civil.Date · datetime.date"]) Q -->|"a gap between
two instants"| DUR(["DURATION
time.Duration · timedelta"]) Q -->|"which of two events
on different machines
came first"| LOG(["LOGICAL CLOCK
Lamport · vector · HLC"]) INST -.->|"never store
a naive one"| WARN(["a wall clock with
no zone is not
a point in time"]) CIVIL -.-> WARN LOG -.-> WARN2(["a timestamp cannot
answer this, no matter
how precise"])
And the mapping, concept by concept:
| Concept | Go | Python |
|---|---|---|
| instant | time.Time (kept in UTC) |
datetime with tzinfo |
| duration | time.Duration |
timedelta |
| date | civil.Date |
datetime.date |
| time of day | civil.Time |
datetime.time |
| datetime (civil) | civil.DateTime |
naive datetime |
| period | — use durations | dateutil.relativedelta |
| time zone | *time.Location |
zoneinfo.ZoneInfo |
| elapsed time | time.Since |
time.monotonic() |
| causal order | — no stdlib answer | — no stdlib answer |
Go’s civil package is cloud.google.com/go/civil — worth pulling in when you genuinely mean a date with no instant behind it, like a birthday or an invoice period.
That last row is not an oversight. Neither language ships a logical clock, because there is nothing to ship: a Lamport counter is three lines and a mutex, and the hard parts — who the nodes are, how vectors get pruned, what a conflict means for your data — are all application decisions. If you need one, write it. It is small.
The Short Version#
If you forget the rest of this, keep these:
- Decide which realm you are in before you write the line. Physical or civil. Nearly every date bug is a line of code that never made this choice.
- Prefer physical time. Instants and durations obey arithmetic. Use types that cannot answer civil questions and the civil bugs become unwritable.
- Durations, not periods. Seconds are a quantity. “One month” is an instruction with no agreed meaning.
- IANA names only.
Africa/Nairobi, neverEAT. - A time zone is a bridge, not a field. And it is a bridge with two holes in it every year.
- Store the fact, not the rendering. Ask what should survive a change in the rules.
- Never subtract two wall-clock readings to measure how long something took.
- Across machines, a timestamp does not establish order. Clock skew is routinely larger than the gap you are trying to resolve. If correctness depends on the order, track causality instead.
And write your dates as 2026-08-02. It sorts correctly, it means the same thing in every country, and it has never once been mistaken for the 8th of February.