01/02 03:04:05PM '06: Go's Time Layout Is a Mnemonic
The time package: instants and durations, the arithmetic on both, and the famous reference-date layout string that is an example time rather than a set of format codes, plus tickers, timers, and the monotonic clock. Compiled and run against Go 1.26.5.
Handling time is where a lot of programs quietly go wrong, and Go’s time package is built to keep you on the rails, with one famously strange design choice at its center that turns out to be its best idea. This chapter covers the two core types, the arithmetic you do on them, the layout string that everyone finds bizarre for exactly one day, and the tickers and timers you will use to schedule work. Everything here is run against Go 1.26.5.
Two types: an instant and a span
The package has two nouns you use constantly. A time.Time is an instant, a specific point on the calendar with a location: “2026-07-10 at 09:00 UTC.” A time.Duration is a span, a length of time with no position: “ninety minutes.” Keeping them straight matters, because the operations that make sense differ. You can subtract two instants to get a span, and you can add a span to an instant to get another instant, but adding two instants is meaningless and the type system will not let you.
time.Now() gives you the current instant. time.Date(...) constructs a specific one, which is what you want in examples and tests where a moving “now” would make the output impossible to check:
launch := time.Date(2026, time.July, 10, 9, 0, 0, 0, time.UTC)
meeting := launch.Add(90 * time.Minute)
A Duration under the hood is just an int64 count of nanoseconds, and you build one by multiplying a named constant: 90 * time.Minute, 500 * time.Millisecond, 3 * time.Hour. That is why the arithmetic reads naturally and why a Duration prints itself in human units. One consequence of the nanosecond int64 is a ceiling worth knowing: the largest representable Duration is about 292 years, so it comfortably covers timeouts and intervals but is not the type for calendar-scale spans, which is precisely the job AddDate exists to do.
A time.Time also carries a location, its time zone. The instants above are in time.UTC, which is what you almost always want for anything you store or compare, because UTC has no daylight-saving discontinuities. time.Local gives you the machine’s configured zone for display, and time.LoadLocation("America/New_York") loads a named zone from the system’s tzdata. Store and reason in UTC; convert to a local zone only at the edge where a human reads the result.
Doing arithmetic on time
The methods you reach for form a small, memorable set. Add moves an instant forward (or back, with a negative duration) by a fixed span. Sub subtracts two instants to yield the Duration between them. AddDate shifts by calendar units (years, months, days) rather than fixed durations, which matters across month boundaries and daylight-saving changes where “one day” is not always 24 hours. And Since(t) and Until(t) are shorthands for Now().Sub(t) and t.Sub(Now()), the two you use most in real code for “how long ago” and “how long until.”
package main
import (
"fmt"
"time"
)
func main() {
launch := time.Date(2026, time.July, 10, 9, 0, 0, 0, time.UTC)
meeting := launch.Add(90 * time.Minute)
fmt.Println("launch: ", launch.Format(time.RFC3339))
fmt.Println("meeting:", meeting.Format(time.RFC3339))
gap := meeting.Sub(launch)
fmt.Println("gap: ", gap)
nextDay := launch.AddDate(0, 0, 1)
fmt.Println("nextDay:", nextDay.Format(time.RFC3339))
elapsed := meeting.Sub(launch)
fmt.Printf("elapsed in minutes: %.0f\n", elapsed.Minutes())
fmt.Printf("elapsed in seconds: %.0f\n", elapsed.Seconds())
}
launch: 2026-07-10T09:00:00Z
meeting: 2026-07-10T10:30:00Z
gap: 1h30m0s
nextDay: 2026-07-11T09:00:00Z
elapsed in minutes: 90
elapsed in seconds: 5400
Notice gap printing itself as 1h30m0s: a Duration has a String method that renders in the largest sensible units, and it has Minutes(), Seconds(), Hours() methods that return the span as a float in that unit. Sub gave a Duration; AddDate(0, 0, 1) advanced the calendar day; the types kept us honest throughout.
The reference date, Go’s most-mocked and best idea
Every other language formats time with codes: %Y for the year, yyyy-MM-dd, HH:mm:ss. You memorize a table, you get one letter wrong, and you find out at runtime. Go threw the table out. Instead, you write out one specific reference time in the format you want, and Go reads your example as the pattern.
The reference time is always the same instant:
Mon Jan 2 15:04:05 MST 2006
which is the same as 01/02 03:04:05PM '06 -0700. It looks arbitrary until you line up the numbers, and then it is unforgettable. Read the fields in order: month 1, day 2, hour 3, minute 4, second 5, year 6, zone 7 (as -0700). One, two, three, four, five, six, seven. The reference date is a counting sequence. Once you see it you cannot unsee it.
So to format a time, you write out January 2nd 2006 in your desired shape, and to parse, you describe the input the same way:
t := time.Date(2026, time.July, 10, 15, 4, 5, 0, time.FixedZone("MST", -7*3600))
const layout = "01/02 03:04:05PM '06 -0700"
formatted := t.Format(layout)
parsed, err := time.Parse(layout, formatted)
if err != nil {
fmt.Println("parse:", err)
return
}
fmt.Println("formatted:", formatted)
fmt.Println("parsed: ", parsed.Format(layout))
fmt.Println("round-trips equal:", t.Format(layout) == parsed.Format(layout))
formatted: 07/10 03:04:05PM '26 -0700
parsed: 07/10 03:04:05PM '26 -0700
round-trips equal: true
The same layout string drives both directions, which is the real elegance: there is one thing to get right, not two parallel tables for input and output. The one gotcha is that the magic numbers are literal. If you write 2006-01-02 you get a four-digit year; if you slip and write 2007 Go treats it as literal text, not a year field, and your output looks bizarre with a stray 2007 in it. Copy the reference numbers exactly.
For anything a machine will read, do not invent a layout at all. Reach for time.RFC3339, the predefined layout for the ISO 8601 timestamps that logs, JSON, and APIs speak:
t, _ := time.Parse(time.RFC3339, "2026-07-10T15:04:05Z")
fmt.Println("year:", t.Year(), "month:", t.Month(), "day:", t.Day())
fmt.Println("reformatted:", t.Format(time.RFC3339))
year: 2026 month: July day: 10
reformatted: 2026-07-10T15:04:05Z
Use RFC3339 for serialization between systems, and a custom reference-date layout only when a human needs to read the result in some particular local format.
Tickers and timers, and the Stop you owe them
Two types schedule work against the clock. A time.Timer fires once after a delay, delivering the current time on its channel. A time.Ticker fires repeatedly at an interval, delivering on its channel each time. You receive from the .C channel to wait for the next tick:
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
count := 0
for range ticker.C {
count++
fmt.Println("tick", count)
if count == 3 {
break
}
}
timer := time.NewTimer(10 * time.Millisecond)
<-timer.C
fmt.Println("timer fired")
fmt.Println("stop after fire returned:", timer.Stop())
tick 1
tick 2
tick 3
timer fired
stop after fire returned: false
The rule that earns its own paragraph: you must Stop a ticker. A Ticker holds a runtime timer that keeps firing until you stop it, and if you walk away from the loop without stopping it that resource leaks for the life of the process. Deferring ticker.Stop() right after creating it is the habit that prevents the leak. A Timer’s Stop returns a bool telling you whether it stopped the timer before it fired; here it returned false because the timer had already fired and delivered, which is exactly the signal you would check if you were trying to cancel a pending timeout and needed to know whether you won the race. (For the simple “do X after a delay” case where you never cancel, time.After(d) hands you the channel directly and is less to manage.)
The monotonic clock, briefly
One subtlety worth knowing even though you rarely touch it directly. The wall clock on a machine can jump: NTP corrects drift, an operator sets the time, daylight saving shifts it. If you measured an elapsed duration by subtracting two wall-clock readings, one of those jumps could make your measurement negative or wildly wrong. Go defends against this automatically. A time.Time from time.Now() carries two readings, a wall clock and a monotonic clock, and duration math between two such times uses the monotonic component, which only ever moves forward:
start := time.Now()
// ... do some work ...
elapsed := time.Since(start)
fmt.Println("elapsed is positive:", elapsed > 0)
elapsed is positive: true
You get this for free: as long as you measure elapsed time by subtracting two time.Now() values (or use time.Since), the result is immune to the wall clock being adjusted underneath you. The monotonic reading is stripped when you Format, Round, or otherwise convert the time to a wall value, which is the correct behavior, because a formatted timestamp is a wall-clock fact and a stopwatch measurement is not.
The trap that follows: never compare times with ==
That extra monotonic reading has a sharp edge, and it is one of the most common time bugs in Go. A time.Time is a struct — a wall-clock reading, an optional monotonic reading, and a *Location pointer. The == operator compares all of that field by field. So two time.Time values that name the same instant can compare unequal with ==, because their internal representations differ even though the moment they point at does not.
Watch it happen. t1 comes from time.Now(), so it carries a monotonic reading. t1.Round(0) produces the same instant with the monotonic reading stripped — same moment, different struct:
t1 := time.Now()
t2 := t1.Round(0) // same instant, monotonic reading removed
fmt.Println("t1 == t2 :", t1 == t2)
fmt.Println("t1.Equal(t2) :", t1.Equal(t2))
t1 == t2 : false
t1.Equal(t2) : true
== says false; .Equal says true. .Equal is the method built for exactly this — it compares the instant, ignoring the monotonic reading and normalizing the zone, which is what you almost always mean by “are these the same time.”
The zone is the second way == lies. The same instant expressed in two different locations is two different structs, because the *Location pointers differ:
utc := time.Date(2026, time.July, 31, 12, 0, 0, 0, time.UTC)
ny, _ := time.LoadLocation("America/New_York")
east := utc.In(ny) // 08:00 in New York — the very same instant
fmt.Println("utc == east :", utc == east)
fmt.Println("utc.Equal(east):", utc.Equal(east))
utc == east : false
utc.Equal(east): true
Noon UTC and 8 AM Eastern are the same point on the timeline, but == compares the location pointers and reports false. .Equal compares the instant and reports true.
The rule is short and absolute: never compare time.Time values with ==. Use .Equal for “same instant,” and .Before / .After for ordering. The one place == is defensible is testing whether a time is the zero value, and even there t.IsZero() says what you mean. The == operator on times isn’t broken — it faithfully compares the struct — but the struct is not the instant, and the instant is what you care about.
Final thoughts
The time package splits cleanly into instants (time.Time) and spans (time.Duration), with arithmetic (Add, Sub, AddDate, Since, Until) that respects the difference. Its signature move is the reference-date layout: you format and parse by writing out 01/02 03:04:05PM '06 -0700, a counting mnemonic, instead of memorizing format codes, and you fall back to time.RFC3339 whenever a machine is on the other end. Schedule with Timer and Ticker, always Stop the ticker, and trust the monotonic clock to make your elapsed-time measurements correct even when the wall clock is not — but never compare two times with ==, because that same monotonic reading and the zone pointer make it lie; reach for .Equal, .Before, and .After instead.
Comments