time
time.Time and time.Duration
Section titled “time.Time and time.Duration”The time package provides two central types:
time.Time— an instant in time with nanosecond precision, always carrying a location (timezone).time.Duration— a signed 64-bit integer representing a length of time in nanoseconds. Duration constants liketime.Hour,time.Minute,time.Second, andtime.Millisecondmake arithmetic readable.
d := 2*time.Hour + 30*time.Minute + 15*time.Secondfmt.Println(d) // 2h30m15sConstructing a time.Time
Section titled “Constructing a time.Time”In production code you call time.Now() to get the current instant. For tests and deterministic examples, use time.Date:
t := time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC)// year, month, day, hour, min, sec, nanosec, locationtime.Month values (time.January through time.December) are typed constants; you can also pass integer literals (1–12) because they are assignable to time.Month.
The monotonic clock: Equal vs ==
Section titled “The monotonic clock: Equal vs ==”time.Now() reads both the wall clock and, on most platforms, a monotonic clock reading, and stores both inside the time.Time value. The monotonic reading only ever moves forward, even if the OS clock is adjusted by NTP or a manual change — which is exactly what Since and Sub use, so measuring an elapsed duration is safe from clock jumps.
This dual reading is also why == and .Equal() can disagree on what looks like the same instant:
a := time.Now()b := a // identical copy — a == b is truec := a.Round(0) // Round(0) strips the monotonic readingfmt.Println(a == b, a.Equal(b)) // true truefmt.Println(a == c, a.Equal(c)) // false true== compares the entire struct — wall clock, monotonic reading, and location all included — so two Time values representing the same instant can compare unequal if one has had its monotonic reading stripped (by Round, by a round-trip through Marshal/Unmarshal, or by time.Parse). Equal compares only the instant in time and ignores all of that. Always use Equal to compare two time.Time values, never ==, unless you specifically need to detect a stripped monotonic reading.
The n-times-time.Second unit bug and time.Sleep(5)
Section titled “The n-times-time.Second unit bug and time.Sleep(5)”Duration constants (time.Second, time.Millisecond, …) exist so a plain integer is never mistaken for a duration — but two related mistakes still show up constantly in AI-generated code:
var n time.Duration = 2d := n * time.Second // fine: n is already a Durationfmt.Println(d) // 2sThe bug shows up when n is a plain int instead of time.Duration: n * time.Second where n int does not even compile (“mismatched types”), which is Go protecting you — but time.Sleep(n) compiles fine for any int-convertible value, and time.Sleep(5) sleeps for 5 nanoseconds, not 5 seconds, because Sleep takes a Duration and 5 is a bare nanosecond count once it loses the multiplication:
time.Sleep(5) // sleeps 5 nanoseconds — almost certainly not what was intendedtime.Sleep(5 * time.Second) // sleeps 5 seconds — the constant makes the unit explicitReading a diff, time.Sleep (or context.WithTimeout, time.NewTimer, …) called with a bare integer literal without a time.Xxx unit attached is worth a second look every time.
Formatting — the reference layout
Section titled “Formatting — the reference layout”Go uses a concrete reference time instead of abstract format codes like YYYY-MM-DD. The reference moment is:
Mon Jan 2 15:04:05 MST 2006To format a time, write out the reference moment in the shape you want:
t.Format("2006-01-02") // "2024-01-02"t.Format("15:04:05") // "15:04:05"t.Format("02 Jan 2006 15:04 MST") // "02 Jan 2024 15:04 UTC"t.Format(time.RFC3339) // "2024-01-02T15:04:05Z"Go 1.20 added named layout constants for the most common shapes, so you no longer have to retype the reference moment for them: time.DateTime ("2006-01-02 15:04:05"), time.DateOnly ("2006-01-02"), and time.TimeOnly ("15:04:05").
Parsing
Section titled “Parsing”time.Parse is the inverse of Format — it takes a layout and a string:
parsed, err := time.Parse("2006-01-02", "2024-06-15")// parsed is midnight UTC on June 15, 2024The layout string must match the reference time components you used — the same characters in the same positions.
time.Parse always returns a Time in UTC when the layout has no timezone component — it never assumes the machine’s local timezone. When the input string represents a local time in a specific timezone (a timestamp from a log file written in Asia/Bangkok, say), use time.ParseInLocation(layout, value, loc) instead, which interprets the string as wall-clock time in loc rather than UTC:
loc, _ := time.LoadLocation("Asia/Bangkok")p1, _ := time.Parse("2006-01-02 15:04:05", "2026-09-13 14:30:00") // UTCp2, _ := time.ParseInLocation("2006-01-02 15:04:05", "2026-09-13 14:30:00", loc) // Asia/Bangkok// p1 and p2 represent different instants even though the digits are identicalArithmetic — Add and Sub
Section titled “Arithmetic — Add and Sub”deadline := t.Add(72 * time.Hour) // 3 days laterdiff := deadline.Sub(t) // time.Duration: 72h0m0sbefore := deadline.Before(t) // falseafter := deadline.After(t) // trueAdd takes a Duration and returns a Time. Sub takes two Time values and returns the Duration between them.
AddDate(years, months, days) is calendar arithmetic, not duration arithmetic, and it can overflow a month in a way that surprises AI-generated “add one month” code: time.Date(2026, 1, 31, ...).AddDate(0, 1, 0) does not clamp to the last day of February — it normalizes February 31st forward into March, producing March 3rd (2026 is not a leap year). Any code adding months to a date near month-end needs to account for this explicitly.
Timers, tickers, and their channels
Section titled “Timers, tickers, and their channels”time.After, time.NewTimer, and time.NewTicker all deliver on a channel. Two semantics changed recently and are now the only behaviour on Go 1.27:
- Timer channels are unbuffered. Since Go 1.23 (for
go 1.23+modules) a timer’s channel is synchronous: a value is only ever seen after the timer fires, andt.Stop()/t.Reset()guarantee no stale value is delivered afterwards — the old “drain the channel after Stop” ritual is gone. TheasynctimerchanGODEBUG escape hatch was removed in Go 1.27, so every program gets this behaviour. - Unreferenced timers are garbage-collected. A
time.Afterinside aselectin a loop no longer leaks until it fires; once nothing references the timer it can be collected.
// given ctx context.Contexttimer := time.NewTimer(50 * time.Millisecond)select {case <-timer.C: fmt.Println("fired")case <-ctx.Done(): timer.Stop() // no drain needed on Go 1.23+ semantics}Still prefer time.NewTicker over time.Tick in long-lived code so you can Stop() it, and context.WithTimeout over hand-rolled timers when the deadline belongs to a request.
Before Go 1.23, time.Tick was a guaranteed leak. It returns only a channel, with no handle to call Stop() on, so the ticker behind it could never be freed by the garbage collector — idiomatic pre-1.23 code always used time.NewTicker instead, specifically so it had something to Stop(). As of Go 1.23, the garbage collector can recover an unreferenced Ticker (or Timer) even without Stop(), and the standard library’s own documentation for time.Tick now says “there is no longer any reason to prefer NewTicker when Tick will do.” Stop() still matters when you need to actually halt delivery while other references to the ticker stay alive — it is no longer required just to avoid a leak. Code (or an AI assistant) that still insists “never use time.Tick, it always leaks” is repeating pre-1.23 folklore.
Injecting a clock for tests
Section titled “Injecting a clock for tests”Code that calls time.Now() directly is hard to test deterministically — the “right” answer changes every time the test runs. The common fix is to accept a clock as a function value instead of calling time.Now() inline, so a test can substitute a fixed time:
func isExpired(now func() time.Time, deadline time.Time) bool { return now().After(deadline)}
// production: isExpired(time.Now, deadline)// test: isExpired(func() time.Time { return fixedTime }, deadline)This is the same dependency-injection idea used for a database connection or an HTTP client — pass the thing that varies as a parameter (or a small interface with a Now() time.Time method) instead of reaching for a global.
package main
import ( "fmt" "time")
func main() { // Fixed time — deterministic in any interpreter t := time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC) fmt.Println(t)
// Formatting with the reference layout fmt.Println(t.Format("2006-01-02")) fmt.Println(t.Format("15:04:05")) fmt.Println(t.Format("Mon, 02 Jan 2006 15:04:05 MST")) fmt.Println(t.Format(time.RFC3339))
// Parsing parsed, err := time.Parse("2006-01-02", "2024-06-15") if err == nil { fmt.Println(parsed.Format("January 2, 2006")) }
// Arithmetic deadline := t.Add(72 * time.Hour) fmt.Println(deadline.Format("2006-01-02 15:04:05"))
diff := deadline.Sub(t) fmt.Println(diff)
// Duration construction d := 2*time.Hour + 30*time.Minute fmt.Println(d)
// Accessing date parts fmt.Println(t.Year(), t.Month(), t.Day()) fmt.Println(t.Hour(), t.Minute(), t.Second())} package main
import ( "fmt" "time")
func main() { // 1. monotonic clock: Since/Sub use it even if the wall clock jumps; // Equal compares the instant, == compares the whole struct (including monotonic reading + location) a := time.Now() b := a // identical copy fmt.Println("a == b:", a == b, "a.Equal(b):", a.Equal(b))
c := a.Round(0) // strips the monotonic reading fmt.Println("a == c (monotonic stripped):", a == c, "a.Equal(c):", a.Equal(c))
// 2. the n*time.Second unit bug: n is already a Duration once multiplied, // but a bare integer like 5 passed to Sleep means 5 nanoseconds, not 5 seconds var n time.Duration = 2 d := n * time.Second fmt.Println("2 * time.Second:", d) // time.Sleep(5) sleeps 5 nanoseconds — a classic unit bug, not "5 seconds" start := time.Now() time.Sleep(5) fmt.Println("Sleep(5) slept for less than a millisecond:", time.Since(start) < time.Millisecond)
// 3. time.DateTime and friends (Go 1.20) t := time.Date(2026, 9, 13, 14, 30, 0, 0, time.UTC) fmt.Println("time.DateTime:", t.Format(time.DateTime)) fmt.Println("time.DateOnly:", t.Format(time.DateOnly)) fmt.Println("time.TimeOnly:", t.Format(time.TimeOnly))
// 4. Parse returns UTC; ParseInLocation uses the given location p1, _ := time.Parse("2006-01-02 15:04:05", "2026-09-13 14:30:00") fmt.Println("Parse location:", p1.Location()) loc, _ := time.LoadLocation("Asia/Bangkok") p2, _ := time.ParseInLocation("2006-01-02 15:04:05", "2026-09-13 14:30:00", loc) fmt.Println("ParseInLocation location:", p2.Location(), "same instant as UTC 07:30:", p2.UTC().Format(time.TimeOnly))
// 5. Ticker.Stop and AddDate month overflow ticker := time.NewTicker(time.Hour) ticker.Stop() // stops delivery now; not required for GC since Go 1.23, but still good practice
jan31 := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) overflowed := jan31.AddDate(0, 1, 0) // "add one month" overflows into March fmt.Println("Jan 31 + 1 month:", overflowed.Format(time.DateOnly))
// 6. injecting a clock for tests: accept a func() time.Time instead of calling time.Now directly fixedClock := func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } isExpired := func(now func() time.Time, deadline time.Time) bool { return now().After(deadline) } fmt.Println("isExpired with injected clock:", isExpired(fixedClock, time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)))} Real output from this program on Go 1.26.5 (the boolean comparisons make this deterministic despite calling time.Now()):
a == b: true a.Equal(b): truea == c (monotonic stripped): false a.Equal(c): true2 * time.Second: 2sSleep(5) slept for less than a millisecond: truetime.DateTime: 2026-09-13 14:30:00time.DateOnly: 2026-09-13time.TimeOnly: 14:30:00Parse location: UTCParseInLocation location: Asia/Bangkok same instant as UTC 07:30: 07:30:00Jan 31 + 1 month: 2026-03-03isExpired with injected clock: trueNote that every assertion above is a relationship (== vs Equal, “less than a millisecond”, a location name, a fixed calendar date) rather than a wall-clock value — the only way to keep a time.Now()-based example’s output the same on every run.
The monotonic vs wall clock reading
Section titled “The monotonic vs wall clock reading”flowchart LR Now["time.Now()"] --> T["time.Time value"] T --> Wall["wall clock reading"] T --> Mono["monotonic reading"] Wall -- "affected by NTP / manual changes" --> Format["Format() / == comparison"] Mono -- "always moves forward" --> SinceSub["Since() / Sub()"] T -- "Round(0) strips monotonic" --> T2["Time value: wall clock only"]
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| time.Time as a value type | Immutable, safe to copy and share | Every Time carries a timezone you must account for |
| time.Duration (int64 nanoseconds) | Unambiguous arithmetic — 5*time.Second | Must multiply by a named constant, not just write “5” |
| Reference layout (“2006-01-02”) | Self-documenting once you know the example | Different from other languages’ YYYY/MM/DD — you have to memorize the magic numbers |
Common Misconceptions
Section titled “Common Misconceptions”- time.Now() always returns UTC — it depends on the machine’s local timezone; call
time.Now().UTC()when you need UTC explicitly. - time.Sleep(1 * time.Second) is precise — the OS scheduler may sleep longer than requested, e.g. 1.05s — it is not hard real-time.
- time.Equal is the same as == —
time.Equalcompares the instant in time (ignoring timezone);==compares the entire memory layout, timezone included. - time.Sleep(5) sleeps 5 seconds — it sleeps 5 nanoseconds;
Sleeptakes atime.Duration, and a bare integer literal is a nanosecond count, not a unit-agnostic “amount of time.” - time.Tick always leaks if you don’t call Stop() — true before Go 1.23, when an unreferenced Ticker could never be GC’d; since Go 1.23 the garbage collector recovers an unreferenced Ticker or Timer on its own, so this is now outdated advice (Stop() is still useful to halt ticking while a reference is kept alive, just not required to avoid a leak).
- AddDate(0, 1, 0) always lands on the same day next month — near month-end it can roll over into the following month instead (Jan 31 + 1 month ≠ Feb 31, which does not exist).
💡 Real-world example
net/http.Server:
http.Server{ReadHeaderTimeout: 5 * time.Second}sets a timeout as atime.Durationto stop a slow-header client from holding a connection open.context.WithTimeout: creates a deadline that must propagate across function calls — for example, calling a downstream service and cancelling it automatically when time runs out.
net/http.TimeoutHandler: derives a
context.WithTimeoutfromr.Context()and selects onctx.Done()to cut off a request when the handler runs too long.
Reading AI-generated code
Section titled “Reading AI-generated code”time code compiles regardless of whether the units or the timezone are right, so these are worth checking by hand every time:
- Does
time.Sleep,context.WithTimeout, ortime.NewTimertake a bare integer literal instead of a value multiplied by atime.Xxxconstant? - Does code compare two
time.Timevalues with==instead of.Equal()— especially after one of them went throughParse,Round, or a database round-trip? - Does code call
time.Parseon a timestamp that is known to represent local time in a specific timezone, whenParseInLocationis what is needed? - Does an
AddDate(0, n, 0)(orAddDate(n, 0, 0)) run near month-end (or Feb 29) without accounting for calendar overflow? - Does a comment or explanation claim
time.Tick/an unstoppedTicker“always leaks” — a true statement before Go 1.23, but no longer the whole story? - Does test code call
time.Now()directly inside the function under test, instead of accepting an injected clock?
🐛 Spot the bug
func retryWithBackoff(attempt int, do func() error) error { for i := 0; i < attempt; i++ { if err := do(); err == nil { return nil } time.Sleep(5) // wait 5 seconds before retrying } return fmt.Errorf("failed after %d attempts", attempt)}Show the answer
The comment says “5 seconds”, but time.Sleep takes a time.Duration — an int64 count of nanoseconds — and 5 with no unit attached means 5 nanoseconds, not 5 seconds. The retry loop effectively has no backoff delay at all; it hammers do() as fast as the CPU allows. Fix it by multiplying by the named constant:
time.Sleep(5 * time.Second)