54. `time`

📖 Reading · 14 min
💡 Every code box below is live — edit it and hit Run.

Go's time package has one famously strange design decision and a lot of very good ones. This lesson covers both, plus the parts you'll use daily: durations, formatting, arithmetic and measuring how long things took.

Two types: Time and Duration

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, time.March, 15, 10, 30, 0, 0, time.UTC)

    fmt.Println(t)
    fmt.Println(t.Year(), t.Month(), t.Day())
    fmt.Println(t.Hour(), t.Minute(), t.Weekday())
    fmt.Println(t.YearDay(), t.Unix())

    d := 90 * time.Minute
    fmt.Println(d, d.Hours(), d.Minutes(), d.Seconds())
}
  • time.Time is an instant. It's a value type, safe to copy, and immutable — every method returns a new Time.
  • time.Duration is a span of time, stored as an int64 count of nanoseconds.

We use a fixed date here so the output is stable; in real code you'd start with time.Now().

Durations are numbers with units

This is the design that makes time pleasant:

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println(time.Second, time.Millisecond, time.Hour)

    timeout := 30 * time.Second
    retry := 500 * time.Millisecond
    long := 2*time.Hour + 45*time.Minute

    fmt.Println(timeout, retry, long)
    fmt.Println("retries that fit in the timeout:", int64(timeout/retry))
    fmt.Println(long.Round(time.Hour))

    parsed, err := time.ParseDuration("1h30m")
    fmt.Println(parsed, parsed.Minutes(), err)
}

Because Duration is an integer type with constants attached, 30 * time.Second is ordinary multiplication, and it prints as 30s thanks to a String() method. No "is this milliseconds or seconds?" — the units are in the type.

One gotcha: multiplying by a variable needs a conversion, because Go won't mix int and Duration:

package main

import (
    "fmt"
    "time"
)

func main() {
    n := 5

    // d := n * time.Second // won't compile
    d := time.Duration(n) * time.Second
    fmt.Println(d)

    ms := 250
    fmt.Println(time.Duration(ms) * time.Millisecond)
}

The reference-time layout

Here's the famous part. Go doesn't use YYYY-MM-DD. It formats by example, using one specific reference time:

Mon Jan 2 15:04:05 MST 2006
 |   |  |  |  |  |   |   |
 |   |  |  |  |  |   |   +-- 2006 = year
 |   |  |  |  |  |   +------ MST  = timezone
 |   |  |  |  |  +---------- 05   = second
 |   |  |  |  +------------- 04   = minute
 |   |  |  +---------------- 15   = hour (24h)
 |   |  +------------------- 2    = day
 |   +---------------------- Jan  = month
 +-------------------------- Mon  = weekday

The numbers are a mnemonic: 1 2 3 4 5 6 7 — month, day, hour, minute, second, year, timezone offset.

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, time.March, 15, 14, 30, 45, 0, time.UTC)

    fmt.Println(t.Format("2006-01-02"))
    fmt.Println(t.Format("02/01/2006 15:04"))
    fmt.Println(t.Format("Jan 2, 2006 at 3:04 PM"))
    fmt.Println(t.Format("Monday, January 2"))
    fmt.Println(t.Format(time.RFC3339))
    fmt.Println(t.Format(time.RFC1123))
    fmt.Println(t.Format("15:04:05.000"))
}

You write the layout as how the reference time should look. It's odd for five minutes and then genuinely easier than remembering whether MM is months or minutes.

The built-in layouts cover most cases — time.RFC3339 is what you want for APIs, logs and anything machine-readable.

Parsing

Same layout string, in reverse:

package main

import (
    "fmt"
    "time"
)

func main() {
    t, err := time.Parse("2006-01-02", "2024-03-15")
    fmt.Println(t.Format(time.RFC1123), err)

    _, err = time.Parse("2006-01-02", "15/03/2024")
    fmt.Println("mismatch ->", err != nil)

    ts, _ := time.Parse(time.RFC3339, "2024-03-15T14:30:45Z")
    fmt.Println(ts.Hour(), ts.Minute())
}

time.Parse returns an error when the input doesn't match the layout — one more (value, error) pair to check.

Arithmetic

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, time.March, 15, 10, 0, 0, 0, time.UTC)

    fmt.Println(t.Add(90 * time.Minute).Format("15:04"))
    fmt.Println(t.Add(-2 * time.Hour).Format("15:04"))

    fmt.Println(t.AddDate(0, 1, 0).Format("2006-01-02"))
    fmt.Println(t.AddDate(1, 0, -15).Format("2006-01-02"))

    later := t.Add(36 * time.Hour)
    diff := later.Sub(t)
    fmt.Println(diff, diff.Hours())

    fmt.Println(later.After(t), later.Before(t), later.Equal(t))
}
  • Add takes a Duration — good for hours and minutes.
  • AddDate(years, months, days) handles calendar arithmetic, including month lengths and leap years.
  • Sub gives you a Duration between two times.
  • Compare with After, Before, Equalnot ==, which also compares the monotonic clock reading and the location pointer.

Measuring elapsed time

package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()

    total := 0
    for i := 0; i < 200000; i++ {
        total += i
    }

    elapsed := time.Since(start)

    fmt.Println("sum:", total)
    fmt.Println("took a measurable amount of time:", elapsed > 0)
    fmt.Println("under a second:", elapsed < time.Second)
}

time.Since(start) is shorthand for time.Now().Sub(start), and it's the standard way to time an operation. A time.Time from time.Now() carries a monotonic clock reading, so Since stays correct even if the system clock is adjusted mid-measurement.

The deferred-timer idiom, using closures from module 3:

package main

import (
    "fmt"
    "time"
)

func timed(name string) func() {
    start := time.Now()
    return func() {
        fmt.Printf("%s finished in under a second: %t\n", name, time.Since(start) < time.Second)
    }
}

func work() {
    defer timed("work")()

    sum := 0
    for i := 0; i < 100000; i++ {
        sum += i
    }
}

func main() {
    work()
}

Note the double parentheses in defer timed("work")(): timed("work") runs now (recording the start), and the function it returns is what gets deferred.

Sleeping, timers and tickers

package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Now()
    time.Sleep(20 * time.Millisecond)
    fmt.Println("slept at least 20ms:", time.Since(start) >= 20*time.Millisecond)

    select {
    case <-time.After(10 * time.Millisecond):
        fmt.Println("time.After fired")
    }

    ticker := time.NewTicker(5 * time.Millisecond)
    defer ticker.Stop()

    ticks := 0
    for range ticker.C {
        ticks++
        if ticks == 3 {
            break
        }
    }
    fmt.Println("ticks received:", ticks)
}

time.After returns a channel — that's what made the timeout select in the concurrency module work. time.NewTicker fires repeatedly on its C channel; always defer ticker.Stop(), or its goroutine and timer leak.

Time zones

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, time.March, 15, 14, 0, 0, 0, time.UTC)

    fmt.Println(t.Format("15:04 MST"))
    fmt.Println(t.UTC().Format(time.RFC3339))
    fmt.Println(t.Unix(), "seconds since the epoch")

    local := t.In(time.Local)
    fmt.Println("same instant, local wall clock:", local.Unix() == t.Unix())
}

A time.Time always carries a location. In(loc) changes how it displays without changing the instant — which is why the Unix timestamps match.

The professional habit: store and transmit UTC, convert to local only for display. time.Now() gives you local time, so servers usually call time.Now().UTC().

Your turn

Given a start date, print the date 45 days later and the number of hours between them:

2024-04-29
1080
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Date(2024, time.March, 15, 0, 0, 0, 0, time.UTC)
    // print start+45 days as 2006-01-02, then the hours between them
}
package main

import (
    "fmt"
    "time"
)

func main() {
    start := time.Date(2024, time.March, 15, 0, 0, 0, 0, time.UTC)

    end := start.AddDate(0, 0, 45)
    fmt.Println(end.Format("2006-01-02"))
    fmt.Println(int(end.Sub(start).Hours()))
}

Next: talking to the outside world in JSON.