Time and Scheduling

Updated

September 13, 2026

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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

  • Time for instants, Duration for lengths. Do not mix them in a raw int.
  • Pass now time.Time into business functions.
  • Store UTC (or a named location you control). Parse with the zone attached.
  • Timer once, Ticker many. Stop both.
  • Do not Sleep to mean “the clock moved” or “the other goroutine finished.”
  • time.Until / time.Since instead of Now().Sub(...) when that is what you mean.

Try this

  1. In shift_length.go, print end.Before(start) and start.Add(d).Equal(end).
  2. In open.go, add a noon instant and print open(noon).
  3. In ticker.go, drop defer tk.Stop() and add a break after one tick. Then put Stop back — the break is the case that needs it.
  4. In timer.go, add a done channel, close it, and select on t.C and done. Confirm you can skip shift started when done is already closed.