DEV Community

Cover image for Go's time Package: Monotonic Clocks and the Bugs They Prevent
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go's time Package: Monotonic Clocks and the Bugs They Prevent


You add a log line that measures how long a request took. You
subtract the start time from the end time, print the duration, and
ship it. A week later the dashboard shows a request that took
-1.4s. Negative. Time went backwards.

It did, in a way. An NTP daemon noticed the machine's wall clock had
drifted, and it stepped the clock back a second to correct it. Your
end timestamp landed before your start timestamp. Before Go 1.9 this
was a real class of bug in Go services, and the fix that landed then
is the reason you rarely see it today: every time.Time from
time.Now() carries a second, hidden clock. Understanding that
hidden clock explains both why Sub is safe and why a timestamp that
survives a round-trip through JSON is not.

The change came out of Russ Cox's design doc,
Monotonic Elapsed Time Measurements in Go,
and it's one of the more quietly consequential decisions in the
standard library.

Two clocks inside one time.Time

A time.Time you get from time.Now() holds two readings, not one.

The first is the wall clock: calendar time, the thing you format
as 2026-07-03T14:05:00Z. It's what you want when you ask "what time
is it." It can jump forward or backward whenever something adjusts
the system clock: NTP corrections, an admin running date, a VM
resuming from suspend, a leap-second smear.

The second is the monotonic clock: a counter that only ever moves
forward, measured from some arbitrary point (usually boot). It has no
calendar meaning. You can't format it. But it never jumps, which
makes it the correct thing for measuring elapsed time.

Go stitches both into the same value. You can't see the monotonic
part in the printed output directly, but String appends it after
the wall time with an m= prefix:

t := time.Now()
fmt.Println(t)
// 2026-07-03 14:05:00.123 +0000 UTC m=+0.000123
Enter fullscreen mode Exit fullscreen mode

That m=+0.000123 is the monotonic reading. It's along for the ride.

Why Sub is safe

The rule Go follows is simple once you know it. Time-telling
operations read the wall clock. Time-measuring operations read the
monotonic clock when it's present.

Sub, Since, Until, Before, After, Equal, and Compare
are measuring operations. When both operands carry a monotonic
reading, Go subtracts the monotonic readings and ignores the wall
clock entirely.

start := time.Now()
doWork()
elapsed := time.Since(start)
Enter fullscreen mode Exit fullscreen mode

time.Since(start) is time.Now().Sub(start). Both times come from
time.Now(), so both carry monotonic readings, so the subtraction
uses the monotonic clock. If NTP steps the wall clock backward
between the two calls, elapsed is unaffected. It cannot go negative.
That is the bug the whole feature exists to prevent.

Add cooperates: it adds the duration to both the wall and monotonic
readings, so a computed deadline stays consistent under measurement.

deadline := time.Now().Add(5 * time.Second)
// ...later...
if time.Now().After(deadline) {
    // monotonic comparison, immune to wall-clock jumps
}
Enter fullscreen mode Exit fullscreen mode

As long as you keep the time.Time values in-process and never strip
their monotonic reading, elapsed-time and deadline logic is correct
by construction.

The bug: marshaling strips the monotonic reading

Here's the part that catches people. The monotonic reading has no
meaning outside the current process — its zero point is this
process's boot, not yours. So Go removes it any time a Time leaves
the process or gets normalized.

MarshalJSON, MarshalText, MarshalBinary, and GobEncode all
drop the monotonic reading. Their matching unmarshalers produce a
Time with no monotonic reading at all. time.Parse, time.Unix,
and time.Date do the same — a Time built from those never had a
monotonic reading to begin with.

Watch what that does to a measurement:

start := time.Now()

b, _ := json.Marshal(start)
var restored time.Time
_ = json.Unmarshal(b, &restored)

time.Sleep(10 * time.Millisecond)

fmt.Println(time.Since(start))    // monotonic, correct
fmt.Println(time.Since(restored)) // wall clock, exposed
Enter fullscreen mode Exit fullscreen mode

time.Since(start) uses the monotonic clock. time.Since(restored)
can't: restored came out of JSON with no monotonic reading, so
Sub falls back to the wall clock for that operand. The documented
rule is that if either operand lacks a monotonic reading, the
operation uses wall clock readings for both.

That fallback is invisible in tests. On a machine with a steady clock
the two subtractions agree to the microsecond. The gap only opens
when the wall clock jumps — exactly the rare, hard-to-reproduce
condition the monotonic clock was meant to guard against. You didn't
lose the guarantee at the call site. You lost it three layers away,
in a struct that got serialized to Redis and read back.

The lesson: a timestamp you plan to measure against must stay a
live, in-process time.Time. The moment it round-trips through any
serializer, treat it as a plain wall-clock instant.

== is a trap, use Equal

The monotonic reading also breaks the == operator, and this one
bites even without serialization.

== on a time.Time compares the whole struct: wall reading,
monotonic reading, and *Location pointer. Two times that represent
the same instant can compare unequal because one carries a monotonic
reading and the other doesn't, or because their locations differ.

t1 := time.Now()
t2 := t1.Round(0) // same instant, monotonic stripped

fmt.Println(t1 == t2)      // false
fmt.Println(t1.Equal(t2))  // true
Enter fullscreen mode Exit fullscreen mode

t1.Round(0) is the canonical way to strip the monotonic reading
without changing the wall time. It returns the same instant, so
Equal says true, but the structs differ, so == says false.

The rule is old but worth repeating: never compare time.Time with
==, and never use a raw time.Time as a map or database key
without normalizing it first. Use Equal for instant comparison.
When you do need a canonical key, run it through UTC() and
Round(0) so location and monotonic reading are both gone.

Where the monotonic reading quietly disappears

Marshaling is the obvious stripper. These are the ones that surprise
people, because they look like harmless conversions:

  • t.Round(d) and t.Truncate(d) — any rounding strips it, including the Round(0) idiom.
  • t.UTC(), t.Local(), t.In(loc) — changing the location interpretation strips it too.
  • t.AddDate(y, m, d) — a calendar computation, so wall clock only.

So this innocent-looking normalization silently disarms your
measurement:

start := time.Now().UTC() // convenient, and monotonic-free
doWork()
fmt.Println(time.Since(start)) // wall clock, not monotonic
Enter fullscreen mode Exit fullscreen mode

Calling .UTC() on a start timestamp feels tidy. It also throws away
the exact property you wanted. If you're going to measure against a
value, take it from time.Now() and leave it alone until you're done
measuring. Convert to UTC only when you're about to display or store
it.

What to do on Monday

Four checks on the code you already have.

  1. Any start := time.Now() followed later by .UTC(), .Round, .Truncate, or a marshal before you measure against it. Move the normalization after the measurement, or keep a separate live copy.
  2. Timestamps stored in a struct that gets serialized (JSON to a cache, a DB column, a queue payload) and later fed to Sub or Since. Those measurements run on the wall clock. Decide if that's acceptable; often it is, as long as you know it.
  3. == on time.Time anywhere. Replace with Equal. Grep for it.
  4. time.Time used as a map key or dedup key without UTC() + Round(0) first. Two "equal" instants can hash to different keys.

The monotonic clock is a good default that Go gives you for free. It
prevents the negative-duration bug without you asking. The trap is
that it's fragile in a specific way: it survives arithmetic and
comparison, but not serialization, rounding, or a location change.
Know which of your timestamps are live and which have been flattened,
and the whole thing stays predictable.


Timekeeping is one of those corners of Go that looks trivial until a
production clock jumps under you. The Complete Guide to Go
Programming
digs into the time package and the runtime behavior
underneath it — how the two clock readings are stored and when the
standard library decides to drop one. Hexagonal Architecture in Go
is about keeping details like "is this timestamp still monotonic"
behind a clean boundary, so a serializer at the edge can't quietly
change the meaning of a value in your core.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)