24 — Time & Dates
time.Time — Wall Clock + Monotonic Clock
go
// ┌──────────────────────────────────────────────────────────────────────┐
// │ time.Time contains TWO clocks: │
// │ - Wall clock: the "real world" time (can be changed by NTP/DST) │
// │ - Monotonic clock: never goes backward, unaffected by NTP/DST │
// │ │
// │ time.Now() includes BOTH clocks. │
// │ time.Parse/time.Date include ONLY the wall clock. │
// │ │
// │ Subtraction (t2.Sub(t1)) uses the MONOTONIC clock if both times │
// │ have it — accurate for measuring elapsed time. │
// │ │
// │ == compares BOTH clocks → use t.Equal(t2) for instant comparison. │
// │ Before/After use the monotonic clock if available. │
// └──────────────────────────────────────────────────────────────────────┘
func clocks() {
t1 := time.Now() // has wall + monotonic
time.Sleep(100 * time.Millisecond)
t2 := time.Now() // has wall + monotonic
elapsed := t2.Sub(t1) // uses monotonic clock — accurate even if NTP jumps
fmt.Println(elapsed) // ~100ms (unaffected by wall clock changes)
// Parsed times have NO monotonic clock:
parsed, _ := time.Parse(time.RFC3339, "2024-03-15T14:30:00Z")
// parsed.Sub(t1) uses the wall clock — less reliable for elapsed time
}
The Reference Time — Format by Example
go
// Go formats dates by writing an EXAMPLE of the reference time:
// Mon Jan 2 15:04:05 MST 2006 (= 1/2 3:04:05PM '06 -0700)
//
// Mnemonic: 1 2 3 4 5 6 7
// 1 = month (January)
// 2 = day
// 3 = hour (15 = 24h, 3 = 12h with PM)
// 4 = minute
// 5 = second
// 6 = year (2006)
// 7 = timezone (-0700)
func formatting() {
t := time.Date(2024, 3, 15, 14, 30, 45, 0, time.UTC)
t.Format("2006-01-02") // "2024-03-15"
t.Format("2006-01-02 15:04:05") // "2024-03-15 14:30:45"
t.Format(time.RFC3339) // "2024-03-15T14:30:45Z"
t.Format("Mon Jan _2 15:04:05 2006") // "Fri Mar 15 14:30:45 2024"
t.Format("01/02/2006 3:04 PM") // "03/15/2024 2:30 PM"
t.Format("2006-01-02T15:04:05.999999-07:00") // with microseconds
// Predefined formats:
// time.RFC3339 = "2006-01-02T15:04:05Z07:00"
// time.RFC1123 = "Mon, 02 Jan 2006 15:04:05 MST"
// time.Kitchen = "3:04PM"
// time.Stamp = "Jan _2 15:04:05"
}
// ─── Parsing ───
func parsing() {
t, err := time.Parse(time.RFC3339, "2024-03-15T14:30:45Z")
t, err = time.Parse("2006-01-02", "2024-03-15")
t, err = time.ParseInLocation("2006-01-02", "2024-03-15", time.UTC)
// ⚠️ Layout errors are runtime (not compile-time) — a wrong layout
// silently produces a wrong result or an error. Always check err.
}
== vs Equal — The Comparison Trap
go
// ❌ == compares wall clock AND monotonic clock AND location:
func badCompare() {
t1, _ := time.Parse(time.RFC3339, "2024-03-15T14:30:00Z") // UTC
t2, _ := time.Parse(time.RFC3339, "2024-03-15T09:30:00-05:00") // EST
// t1 and t2 are the SAME INSTANT (14:30 UTC = 09:30 EST)
fmt.Println(t1 == t2) // false — different locations
}
// ✅ Equal compares only the INSTANT (the moment in time):
func goodCompare() {
t1, _ := time.Parse(time.RFC3339, "2024-03-15T14:30:00Z")
t2, _ := time.Parse(time.RFC3339, "2024-03-15T09:30:00-05:00")
fmt.Println(t1.Equal(t2)) // true — same instant, different timezones
}
// Before/After also compare the instant:
func beforeAfter() {
t1 := time.Now()
t2 := t1.Add(time.Hour)
fmt.Println(t1.Before(t2)) // true
fmt.Println(t2.After(t1)) // true
}
Time Zones
go
func timeZones() {
// Load an IANA timezone (requires the tz database):
loc, err := time.LoadLocation("America/New_York")
if err != nil {
// On systems without the tz database, embed it:
// import _ "time/tzdata" // adds ~450KB to binary
panic(err)
}
now := time.Now().In(loc) // display current time in NYC
fmt.Println(now) // 2024-03-15 10:30:00 -0400 EDT
// UTC and Local conversions (change DISPLAY, not instant):
utcTime := now.UTC() // 2024-03-15 14:30:00 +0000 UTC
localTime := now.Local() // in the system's local timezone
// ⚠️ Use IANA names ("America/New_York"), NOT abbreviations ("EST"):
// "EST" has no DST rules — it's always UTC-5, even in summer.
// "America/New_York" handles DST automatically.
loc, _ = time.LoadLocation("EST") // ❌ fixed offset, no DST
loc, _ = time.LoadLocation("America/New_York") // ✅ handles DST
}
Duration — Arithmetic
go
func durationArithmetic() {
// Duration is int64 nanoseconds. Constants:
// time.Nanosecond, Microsecond, Millisecond, Second, Minute, Hour
d := 5 * time.Second
d.Seconds() // 5.0 (float64)
d.Milliseconds() // 5000 (int64)
d.String() // "5s"
// Add/subtract durations:
t := time.Now()
t2 := t.Add(2 * time.Hour) // 2 hours later
t3 := t.Add(-30 * time.Minute) // 30 minutes earlier
// Sub gives a Duration:
diff := t2.Sub(t) // 2h0m0s
// ⚠️ Don't use Add for calendar months (different number of days):
// t.Add(30 * 24 * time.Hour) is NOT "one month" — months vary.
// Use AddDate for calendar units:
t4 := t.AddDate(0, 1, 0) // add 1 calendar month
t5 := t.AddDate(1, 0, 0) // add 1 calendar year
// AddDate(0, 1, 0) on Jan 31 → Feb 28/29 (normalized)
}
Tickers and Timers
go
func tickersAndTimers() {
// ─── Ticker — fires repeatedly at intervals ───
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() // ⚠️ always Stop to release the goroutine
go func() {
for t := range ticker.C { // channel sends a Time each tick
fmt.Println("tick:", t)
}
}()
// ─── Timer — fires once after a duration ───
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
fmt.Println("timer fired")
case <-ctx.Done():
fmt.Println("cancelled before timer fired")
}
// ─── time.After — one-shot timer (leaks if not fired) ───
// ⚠️ time.After leaks the timer goroutine if the select takes another case.
// Use time.NewTimer + Stop in tight loops (see chapter 18).
select {
case v := <-ch:
_ = v
case <-time.After(5 * time.Second):
fmt.Println("timeout")
}
// ─── time.Sleep — blocks the current goroutine ───
time.Sleep(100 * time.Millisecond) // no cancel, no select — rarely right
}
Production Pattern — Scheduled Cleanup
go
func startCleanup(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := cleanupExpiredSessions(ctx); err != nil {
log.Printf("cleanup error: %v", err)
// continue running — don't stop on error
}
case <-ctx.Done():
log.Println("cleanup goroutine shutting down")
return // graceful exit on cancellation
}
}
}
func main() {
ctx, cancel := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer cancel()
go startCleanup(ctx, 5*time.Minute)
// ... main server loop ...
<-ctx.Done()
}
💡 Tips & Tricks
- Idiom: use
t.Equal(t2)to compare times, not==—Equalcompares the instant, ignoring location and monotonic differences.==compares all three and gives false for the same instant in different timezones. - Idiom: use
AddDatefor calendar arithmetic (months, years) —t.AddDate(0, 1, 0)adds a calendar month (handles Feb 28/29).t.Add(30 * 24 * time.Hour)is NOT a month — months have different day counts. - Idiom: use IANA timezone names ("America/New_York"), not abbreviations ("EST") — abbreviations have no DST rules.
time.LoadLocation("America/New_York")handles DST automatically. - Idiom: use
time.Now().Sub(t)(monotonic clock) for measuring elapsed time — it's unaffected by NTP adjustments or DST changes. Parsed times have no monotonic clock. - Idiom: always
defer ticker.Stop()/defer timer.Stop()— without Stop, the timer goroutine lingers (resource leak). In tight loops, usetime.NewTimer+Stopinstead oftime.After(which leaks). - Portability: import
_ "time/tzdata"to embed the timezone database — needed on systems without/usr/share/zoneinfo(some minimal Docker images, Windows). Adds ~450KB to the binary.
⚠️ Edge Cases & Gotchas
==vsEqual:==compares wall + monotonic + location;Equalcompares only the instant. UseEqualfor "same moment."- Parsed times have no monotonic clock:
time.Parsereturns a time with only the wall clock.Subuses the wall clock (less reliable for elapsed time). AddDatenormalizes:time.Date(2024, 1, 31, 0, 0, 0, 0, UTC).AddDate(0, 1, 0)→ Feb 29 (2024 is a leap year). Adding a month to the 31st normalizes to the last day of the target month.time.Sleepcan't be cancelled:time.Sleep(d)blocks the goroutine for d, ignoring context. Usetime.NewTimer+selectwithctx.Done()for cancellable waits.time.Afterleaks: the timer goroutine lives until the duration elapses, even if theselecttook another case. In hot loops, usetime.NewTimer+Stop.time.LoadLocationcan fail: on systems without the tz database, it returns an error. Import_ "time/tzdata"to embed the database.- DST changes: adding
24 * time.Houracross a DST boundary gives 23 or 25 hours of wall-clock time (but 24 hours of monotonic time). UseAddDate(0, 0, 1)for "next day at the same wall-clock time." time.Now()is monotonic:time.Now()includes the monotonic clock. Subtracting twoNow()values gives accurate elapsed time regardless of NTP.- Timezone abbreviations are ambiguous: "CST" can be Central Standard Time (US), China Standard Time, or Cuba Standard Time. Always use IANA names.
🧠 Quick Quiz
go
t1, _ := time.Parse(time.RFC3339, "2024-03-15T14:30:00Z")
t2, _ := time.Parse(time.RFC3339, "2024-03-15T14:30:00Z")
fmt.Println(t1 == t2)
fmt.Println(t1.Equal(t2))
What's printed?
Answer
true
true
Both == and Equal return true here — t1 and t2 are parsed from the same string, so they have the same wall clock, same location (UTC), and both lack a monotonic clock (parsed times don't have one).
The difference between == and Equal appears when the times are in different locations:
t1, _ := time.Parse(time.RFC3339, "2024-03-15T14:30:00Z")
t2, _ := time.Parse(time.RFC3339, "2024-03-15T09:30:00-05:00")
fmt.Println(t1 == t2) // false (different locations)
fmt.Println(t1.Equal(t2)) // true (same instant)
Or when comparing time.Now() values (which have monotonic clocks):
t1 := time.Now()
t2 := t1.In(time.UTC) // same instant, different location
fmt.Println(t1 == t2) // false (monotonic clock is stripped by In())
fmt.Println(t1.Equal(t2)) // true
📚 What's Next
→ 25 — Testing & Benchmarking — table-driven tests, benchmarks, fuzzing, httptest, and coverage.