# Go Time

> Guides the time package correctly — never compare time.Time with == (it compares the wall instant AND the monotonic reading AND the Location, so two values for the same instant can be unequal); use t.Equal/t.Before/t.After. Reference-layout formatting where the magic constant is the date "Mon Jan 2 15:04:05 MST 2006" (so "2006-01-02", not strftime %Y-%m-%d). time.Duration is an int64 of nanoseconds, so a bare integer is nanoseconds (time.Sleep(1) sleeps 1ns) — write typed literals like 5*time.Second. Stop tickers and don't leak time.After in hot loops; measure elapsed with time.Since (monotonic), not wall-clock subtraction; check the time.Parse error. Auto-invokes when writing or editing time.Time comparisons, time formatting/parsing layouts, time.Duration literals, timers/tickers, measuring elapsed time, or on "why does this time format look wrong" / "why is == on times false" requests. The layout is a date, not a format string; the surprises are all in the defaults.

- Skill: `ctoth/go-time` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-time`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-time/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-time

---


# Go Time

> "In general, prefer t.Equal(u) to t == u, since t.Equal uses the most accurate comparison available and correctly handles the case when only one of its arguments has a monotonic clock reading."
> — [time package](https://pkg.go.dev/time)

> "The Time returned by time.Now contains both a wall clock reading and a monotonic clock reading ... later time-measuring operations, specifically comparisons and subtractions, use the monotonic clock reading."
> — [time package, Monotonic Clocks](https://pkg.go.dev/time#hdr-Monotonic_Clocks)

`time.Time` is not a plain instant. It is a struct carrying a wall-clock reading, a `Location`, and (when it comes from `time.Now`) a monotonic-clock reading — and `time`'s formatting layout is a *date*, not a `%`-directive string. Every recurring bug below is one of those facts surprising the author. The policy root (`go-idiomatic-discipline`) bans the swallowed error this skill's §6 surfaces from `time.Parse`; this skill owns the `time` surface.

---

## 1. Never Compare `time.Time` With `==`

`==` on a struct compares every field. For `time.Time` that means the wall reading, the monotonic reading, **and** the `Location` pointer — not just the instant. "Note that the Go == operator compares not just the time instant but also the Location and the monotonic clock reading" ([time](https://pkg.go.dev/time)). So two values naming the *same instant* can be unequal: one from `time.Now()` carries a monotonic reading; the same value after a JSON/binary round-trip does not, because "the serialized forms ... omit the monotonic clock reading" ([time, Monotonic Clocks](https://pkg.go.dev/time#hdr-Monotonic_Clocks)).

```go
// WRONG — == also compares monotonic reading + Location; same instant, but false
if t == other { ... }

// RIGHT — Equal compares the instant, handling a missing monotonic reading correctly
if t.Equal(other) { ... }
if t.Before(deadline) { ... }
if t.After(start) { ... }
```

"If Times t and u both contain monotonic clock readings, the operations `t.After(u)`, `t.Before(u)`, `t.Equal(u)`, `t.Compare(u)`, and `t.Sub(u)` are carried out using the monotonic clock readings alone" ([time, Monotonic Clocks](https://pkg.go.dev/time#hdr-Monotonic_Clocks)). This is **verified** against Go 1.26 in the reference test: a `time.Now()` value and its marshal/unmarshal round-trip name the same instant, yet `withMono == noMono` is `false` while `withMono.Equal(noMono)` is `true`. The same hazard makes a raw `time.Time` a bad map or database key — strip the monotonic reading first (`t.Round(0)`) or key on `t.UnixNano()`.

---

## 2. The Layout Is a Date, Not `strftime`

Go's format/parse layout is the **reference time** `Mon Jan 2 15:04:05 MST 2006` written in your desired arrangement — Unix time `1136239445`, i.e. `01/02 03:04:05PM '06 -0700` ([time](https://pkg.go.dev/time)). You do not write `%Y-%m-%d`; you write the date `2006-01-02`. The mnemonic is the count-up `1 2 3 4 5 6 7` → month=`01`, day=`02`, hour=`15` (or `03`), minute=`04`, second=`05`, year=`06`, zone=`-0700`.

```go
// WRONG — strftime directives produce literal text, not a formatted date
t.Format("%Y-%m-%d")            // => "%Y-%m-%d" with the % treated as literal runes

// RIGHT — the reference date is the layout
t.Format("2006-01-02")          // => "2026-06-25"
t.Format("2006-01-02 15:04:05") // => "2026-06-25 14:30:05"
```

Prefer the named constants over hand-typed magic numbers when one fits: `time.DateOnly` is `"2006-01-02"`, `time.DateTime` is `"2006-01-02 15:04:05"`, `time.TimeOnly` is `"15:04:05"`, `time.RFC3339` is `"2006-01-02T15:04:05Z07:00"` ([time constants](https://pkg.go.dev/time#pkg-constants)). For a zero-padded numeric field use `01`/`02`/`15`; `1`/`2`/`3` mean no padding; `Jan`/`Mon` are the textual month/day. Verified: `Format("2006-01-02")` of 25 June 2026 yields `"2026-06-25"`.

---

## 3. `time.Duration` Is an int64 of Nanoseconds

"A Duration represents the elapsed time between two instants as an int64 nanosecond count" ([time](https://pkg.go.dev/time)). The trap: a Duration is just an integer, so a **bare literal is nanoseconds**, not seconds. `time.Sleep(1)` sleeps one nanosecond; `time.Sleep(5)` sleeps five nanoseconds. You almost never want that — multiply by a unit constant.

```go
// WRONG — these are nanoseconds; this sleeps ~instantly, not for 5 seconds
time.Sleep(5)
ctx, cancel := context.WithTimeout(ctx, 30) // 30 nanoseconds

// RIGHT — typed literals: multiply by the unit
time.Sleep(5 * time.Second)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
d := 100 * time.Millisecond
```

The unit constants (`time.Nanosecond` … `time.Hour`) are themselves `Duration` values; `time.Second` is `1_000_000_000`. Verified: `time.Duration(1) != time.Second`, and `time.Duration(1) == 1*time.Nanosecond`. To turn a *number from outside* (a config field, a flag) into a duration, multiply (`time.Duration(n) * time.Second`) — and prefer parsing a string with `time.ParseDuration("30s")` so the unit is explicit on the wire.

---

## 4. Measure Elapsed Time With `time.Since` (Monotonic), Not Wall Subtraction

Elapsed time must come from the monotonic clock, which only moves forward and is immune to NTP corrections and DST jumps. `time.Since(start)` "is shorthand for `time.Now().Sub(t)`" ([time](https://pkg.go.dev/time)) and uses the monotonic reading when `start` came from `time.Now()`.

```go
// WRONG — re-derives wall times and subtracts; an NTP step or DST change can
// make this negative or wildly off, and it discards the monotonic reading
start := time.Now()
do()
elapsed := time.Now().Sub(start.Round(0)) // Round(0) just stripped the monotonic reading

// RIGHT — Since uses the monotonic clock; non-negative, jump-immune
start := time.Now()
do()
elapsed := time.Since(start)
```

Verified: `time.Since(start)` is always `>= 0`. Beware operations that **strip** the monotonic reading: "`t.AddDate(y, m, d)`, `t.Round(d)`, and `t.Truncate(d)` are wall time computations, they always strip any monotonic clock reading"; so do `t.In`, `t.Local`, `t.UTC` ([time, Monotonic Clocks](https://pkg.go.dev/time#hdr-Monotonic_Clocks)). If you round or change zone, you lose the monotonic reading for later elapsed math. For tests, prefer `testing/synctest` (Go 1.25) over real sleeps — owned by `go-testing-advanced`.

---

## 5. Stop Tickers; Don't Leak `time.After` in a Hot Loop

A `time.Ticker` must be stopped — it is the one timer the GC change below does **not** fully cover for correctness, and a forgotten `Stop()` keeps it firing.

```go
// RIGHT — always stop a ticker you create
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ticker.C:
		poll()
	}
}
```

`time.After` is fine for a one-shot timeout, but in a `select` inside a **hot loop** it allocates a fresh timer on every iteration that lives until it fires:

```go
// WRONG — a new timer per iteration; under a fast inner channel these pile up
for {
	select {
	case v := <-work:
		handle(v)
	case <-time.After(time.Minute): // fresh timer each loop; not reused
		return errTimeout
	}
}

// RIGHT — one context deadline, or one reusable NewTimer, outside the loop
timer := time.NewTimer(time.Minute)
defer timer.Stop()
for {
	select {
	case v := <-work:
		handle(v)
		if !timer.Stop() {
			<-timer.C
		}
		timer.Reset(time.Minute)
	case <-timer.C:
		return errTimeout
	}
}
```

**Go 1.23 changed timer GC and channel buffering** (gated on the module's `go` directive being `1.23.0`+): "`Timer`s and `Ticker`s that are no longer referred to by the program become eligible for garbage collection immediately, even if their `Stop` methods have not been called"; and "the timer channel associated with a `Timer` or `Ticker` is now unbuffered, with capacity 0 ... no stale values prepared before [a `Reset`/`Stop`] call will be sent or received after the call" ([Go 1.23](https://go.dev/doc/go1.23)). So on 1.23+ the old `if !t.Stop() { <-t.C }` drain dance is no longer needed to avoid a stale tick, and an un-stopped one-shot timer no longer leaks memory. Still call `Stop()` on a ticker for *correctness* (to stop the work), and prefer a `context` deadline for request timeouts — see `go-context`. The version floor is owned by `go-version-feature-map`.

---

## 6. `time.Parse` Returns an Error — Check It

`time.Parse(layout, value)` returns `(Time, error)`. A mismatched layout, an out-of-range field, or a bad zone is reported in that error, not by a panic and not by a zero `Time` you can detect after the fact. Discarding it is the policy-root violation (`go-idiomatic-discipline` §4).

```go
// WRONG — a bad input becomes the zero Time (year 1, UTC), silently
t, _ := time.Parse(time.RFC3339, raw)

// RIGHT — check, wrap with context, return
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
	return time.Time{}, fmt.Errorf("parsing timestamp %q: %w", raw, err)
}
```

Verified: parsing a value whose shape does not match the layout returns a non-nil error; a matching layout round-trips (`Parse` then `Format` reproduces the input). Use `time.ParseInLocation` when the input has no zone and you need it interpreted in a specific `Location` rather than UTC. The `%w` wrapping and `errors.Is`/`As` belong to `go-error-handling`.

---

## 7. Zones: `time.UTC`, `time.Local`, and `LoadLocation`

Store and transmit instants in **UTC**; convert to a named zone only for display. `time.UTC` and `time.Local` are always available; any other zone comes from `time.LoadLocation("America/New_York")`, which reads the system (or embedded `time/tzdata`) database and returns an error you must check.

```go
loc, err := time.LoadLocation("America/New_York")
if err != nil {
	return fmt.Errorf("loading zone: %w", err)
}
local := t.UTC().In(loc) // In() strips the monotonic reading (§4) — display only
```

`t.In(loc)`, `t.Local()`, and `t.UTC()` change only the interpretation for display; they do not change the instant — but they **strip the monotonic reading**, so do elapsed-time math before converting. A zero `time.Time{}` is `0001-01-01 00:00:00 UTC`, not "now" and not "unset"; for JSON, drop a zero time with `omitzero` (Go 1.24), not `omitempty` — owned by `go-json`.

---

## 8. Routing to Related Skills

- `go-idiomatic-discipline` — the policy root; §6's checked `time.Parse` error is its "errors are values" floor.
- `go-error-handling` — wrapping the `Parse`/`LoadLocation` error (`%w`, `errors.Is`/`As`).
- `go-json` — the zero `time.Time` that `omitzero` (1.24) drops where `omitempty` leaks `"0001-01-01T00:00:00Z"`.
- `go-context` — `context.WithTimeout`/`WithDeadline` over ad-hoc timers for request cancellation (§5).
- `go-concurrency-goroutines` — a ticker driven in a goroutine loop, stopped via `ctx.Done()`.
- `go-testing-advanced` — `testing/synctest` (1.25) for deterministic time in tests instead of real sleeps.
- `go-version-feature-map` — version floors: the 1.23 timer/ticker GC + channel change, `omitzero` (1.24).

---

## 9. Reference Files

High-frequency `time` anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

