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.

Durations are safe. Periods are not. A duration is a number of seconds and behaves like one. A period is a calendar instruction whose result depends on where you start. Reach for a duration whenever you have the choice.

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.

flowchart LR subgraph physical["PHYSICAL TIME"] I(["instant"]) D(["duration"]) I ---|"−"| D D ---|"+"| I end subgraph civil["CIVIL TIME"] DT(["datetime"]) P(["period"]) DT ---|"−"| P P ---|"+"| DT end I <-->|"TIME ZONE"| DT

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:

flowchart LR DT1(["02:30 on
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.”

If you are in Nairobi like me, 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.

Two timestamps from two machines can only order events that are further apart than the clock skew between them. Inside that window, a timestamp comparison is a coin flip wearing a lab coat.

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:

flowchart TD Q{"what am I
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, never EAT.
  • 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.