Time and Scheduling
Time and Scheduling
time.Time is an instant. time.Duration is a length. The boring default is: store instants, compare them, and let a Timer or Ticker fire. Do not use time.Sleep as the program’s clock.
Mental model
Time holds wall time and a location. t.Add(d) and t.Sub(u) are the arithmetic. t.Before(u), t.After(u), t.Equal(u) are the comparisons. Equal is timezone-aware; == on Time values is not what you want.
Duration is nanoseconds in an int64. Literals look like 20 * time.Millisecond. There is no time.Hour of wall-clock “wait until 8am” — that is a Time plus time.Until.
A Timer fires once. A Ticker fires repeatedly. Both have a C channel and a Stop method. Stop a ticker when you are done. A timer you no longer need should be stopped too, and if Stop returns false you may still need to drain C.
time.Now() is the real clock. In examples and in testable desk logic, pass now time.Time into the function so the clock is an argument, not a hidden Now() call.
Worked examples
Case 1: Time and Duration
Save as shift_length.go. Two instants, one duration.
// shift_length.go
package main
import (
"fmt"
"time"
)
func main() {
start := time.Date(2026, 9, 7, 8, 0, 0, 0, time.UTC)
end := time.Date(2026, 9, 7, 16, 30, 0, 0, time.UTC)
d := end.Sub(start)
fmt.Println(start.Format(time.RFC3339))
fmt.Println(d)
fmt.Println(d.Hours())
}Run:
go run shift_length.goOutput:
2026-09-07T08:00:00Z
8h30m0s
8.5
Format is for people and logs. Sub is for math. Do not parse your own formatted strings back to get a duration.
Case 2: Location
Save as desk_zone.go. The same instant in UTC and on the desk clock.
// desk_zone.go
package main
import (
"fmt"
"time"
)
func main() {
utc := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC)
desk := time.FixedZone("desk", -4*60*60)
local := utc.In(desk)
fmt.Println(utc.Format(time.RFC3339))
fmt.Println(local.Format(time.RFC3339))
fmt.Println(local.Hour())
}Run:
go run desk_zone.goOutput:
2026-09-07T12:00:00Z
2026-09-07T08:00:00-04:00
8
FixedZone always works. time.LoadLocation("America/New_York") needs timezone data on the machine (or import _ "time/tzdata"). For a desk in one building, a fixed offset or a loaded location both beat storing wall-clock strings with no zone.
Case 3: Open window from a clock argument
Save as open.go. Whether the desk is open is a function of now, not of Sleep.
// open.go
package main
import (
"fmt"
"time"
)
func open(now time.Time) bool {
h := now.UTC().Hour()
return h >= 8 && h < 22
}
func main() {
morning := time.Date(2026, 9, 7, 8, 0, 0, 0, time.UTC)
night := time.Date(2026, 9, 7, 23, 0, 0, 0, time.UTC)
fmt.Println("08:00", open(morning))
fmt.Println("23:00", open(night))
}Run:
go run open.goOutput:
08:00 true
23:00 false
Tests pass a Time. Production passes time.Now(). The rule lives in one function.
Case 4: Timer
Save as timer.go. One shot. Stop in defer so a later edit that returns early does not leak the timer.
// timer.go
package main
import (
"fmt"
"time"
)
func main() {
t := time.NewTimer(20 * time.Millisecond)
defer t.Stop()
<-t.C
fmt.Println("shift started")
}Run:
go run timer.goOutput:
shift started
time.After is a timer you cannot stop. Use NewTimer when the function might return before the duration (a context canceled, a ticket arrived). Use After in a select that already has a clear lifetime, as in the channel chapter.
Case 5: Ticker
Save as ticker.go. Three ticks, then stop.
// ticker.go
package main
import (
"fmt"
"time"
)
func main() {
tk := time.NewTicker(15 * time.Millisecond)
defer tk.Stop()
for i := range 3 {
<-tk.C
fmt.Println("tick", i+1)
}
}Run:
go run ticker.goOutput:
tick 1
tick 2
tick 3
Without Stop, the ticker keeps a goroutine and a channel alive until the process exits. In main that is invisible. In a request handler it is a leak. range tk.C without another stop path is a loop that never ends.
The trap
Save as sleep_clock.go. This treats sleep as the definition of “one second later.” The CPU, the scheduler, and a laptop lid disagree.
// sleep_clock.go
package main
import (
"fmt"
"time"
)
func main() {
start := time.Date(2026, 9, 7, 8, 0, 0, 0, time.UTC)
time.Sleep(20 * time.Millisecond)
// wrong idea: "start plus one second" because we slept
fmt.Println("still", start.Format("15:04:05"))
fmt.Println("real now is not start+sleep")
}Run:
go run sleep_clock.goOutput:
still 08:00:00
real now is not start+sleep
start did not move. Sleeping does not advance a Time you already hold. If you need “now,” call time.Now() (or take now as an argument). If you need “wait until 16:00,” compute time.Until(deadline) and use a Timer — and still cancel it if the desk closes early.
A second form of the same trap: time.Sleep(8 * time.Hour) to wait for the next shift. The process cannot react to a cancel. A select on ctx.Done() and time.After(time.Until(open)) can.
The boring rule
Timefor instants,Durationfor lengths. Do not mix them in a rawint.- Pass
now time.Timeinto business functions. - Store UTC (or a named location you control). Parse with the zone attached.
Timeronce,Tickermany.Stopboth.- Do not
Sleepto mean “the clock moved” or “the other goroutine finished.” time.Until/time.Sinceinstead ofNow().Sub(...)when that is what you mean.
Try this
- In
shift_length.go, printend.Before(start)andstart.Add(d).Equal(end). - In
open.go, add a noon instant and printopen(noon). - In
ticker.go, dropdefer tk.Stop()and add abreakafter one tick. Then putStopback — the break is the case that needs it. - In
timer.go, add adonechannel, close it, andselectont.Canddone. Confirm you can skipshift startedwhendoneis already closed.