Durations, Timezones and Monotonic Time in Go

Work with time.Duration, convert between timezones with time.LoadLocation, compare times correctly, and measure elapsed time with the monotonic clock.

Time bugs are hard to spot because they only show up at certain hours, in certain countries or on certain servers. This page covers durations, timezones, comparing times, and why Go's monotonic clock makes measuring elapsed time safe.

time.Duration

A time.Duration is a number of nanoseconds. Build durations by multiplying the unit constants:

package main

import (
	"fmt"
	"time"
)

func main() {
	timeout := 90 * time.Second
	fmt.Println(timeout)
	fmt.Println(timeout.Minutes(), "minutes")

	d, err := time.ParseDuration("1h15m30s")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(d, "=", d.Seconds(), "seconds")

	retries := 3
	wait := time.Duration(retries) * 500 * time.Millisecond
	fmt.Println("total wait:", wait)

	start := time.Date(2026, 1, 31, 9, 0, 0, 0, time.UTC)
	fmt.Println(start.Add(36 * time.Hour).Format(time.DateTime))
	fmt.Println(start.AddDate(0, 1, 0).Format(time.DateOnly))
}
1m30s
1.5 minutes
1h15m30s = 4530 seconds
total wait: 1.5s
2026-02-01 21:00:00
2026-03-03

time.Duration(retries) * ... converts the int first. Writing retries * time.Second with an int variable fails with invalid operation: mismatched types int and time.Duration. A constant like 3 * time.Second works because untyped constants convert automatically.

AddDate adds calendar units. Adding one month to January 31 gives March 3, because February 31 does not exist and Go normalizes it. Keep that in mind for billing dates.

time.Sleep(5) sleeps 5 nanoseconds:

A bare number is nanoseconds. time.Sleep(5) returns immediately. Always write the unit: time.Sleep(5 * time.Second).

Timezones with time.LoadLocation

A time.Time is a moment plus a location used for display. In shows the same moment in another zone:

package main

import (
	"fmt"
	"log"
	"time"
	_ "time/tzdata"
)

func main() {
	kolkata, err := time.LoadLocation("Asia/Kolkata")
	if err != nil {
		log.Fatal(err)
	}
	ny, _ := time.LoadLocation("America/New_York")

	meeting := time.Date(2026, 10, 21, 19, 30, 0, 0, kolkata)
	fmt.Println("Kolkata: ", meeting.Format("2006-01-02 15:04 MST"))
	fmt.Println("New York:", meeting.In(ny).Format("2006-01-02 15:04 MST"))
	fmt.Println("UTC:     ", meeting.UTC().Format("2006-01-02 15:04 MST"))

	local, _ := time.ParseInLocation(time.DateTime, "2026-10-21 09:00:00", ny)
	fmt.Println("parsed:  ", local.Format(time.RFC3339))
}
Kolkata:  2026-10-21 19:30 IST
New York: 2026-10-21 10:00 EDT
UTC:      2026-10-21 14:00 UTC
parsed:   2026-10-21T09:00:00-04:00

Use IANA names like Asia/Kolkata and Europe/London, never fixed offsets, because offsets change with daylight saving time. The rule most teams follow: store and send times in UTC, and convert to the user's zone only when displaying.

unknown time zone in Docker:

LoadLocation reads the timezone database from the operating system. Small images like scratch or alpine without tzdata do not have it, and you get unknown time zone Asia/Kolkata. Importing _ "time/tzdata", as above, embeds the database in your binary (about 450 KB) so it works everywhere.

Comparing Times

package main

import (
	"fmt"
	"time"
)

func main() {
	utc := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
	ist := utc.In(time.FixedZone("IST", 5*3600+1800))

	fmt.Println(utc == ist)
	fmt.Println(utc.Equal(ist))
	fmt.Println(ist.After(utc.Add(-time.Minute)))
	fmt.Println(ist.Sub(utc))
}
false
true
true
0s

== compares the location and internal fields too, so the same moment in two zones is "not equal". Use Equal, Before and After to compare moments, and Sub to get the Duration between them. For the same reason, do not use time.Time as a map key unless every value is in the same location.

Measuring Elapsed Time and the Monotonic Clock

package main

import (
	"fmt"
	"time"
)

func main() {
	start := time.Now()
	time.Sleep(50 * time.Millisecond)
	elapsed := time.Since(start)

	fmt.Println("took at least 50ms:", elapsed >= 50*time.Millisecond)
}
took at least 50ms: true

The wall clock on a computer can jump. NTP corrects it, a user changes it, a VM resumes from sleep. If you measured time by subtracting two wall clock readings, a jump could give a negative or huge result.

time.Now() records two readings: the wall clock and a monotonic clock that only moves forward. time.Since, Sub, Before and After use the monotonic reading when both times have one, so elapsed time stays correct even if the system clock changes in between.

When the monotonic reading is dropped:

t.Round(0), t.UTC(), t.In(loc), and times from time.Date, Parse or JSON decoding have no monotonic reading. That is fine for dates. For timing code, keep the value straight from time.Now() and use time.Since. If you print a time.Now() value you may see m=+0.000012 at the end, which is the monotonic reading.

Your time code is correct:

If durations always carry a unit, times are stored in UTC and shown in IANA zones, comparisons use Equal, and timing uses time.Since, you have avoided the time bugs that show up only twice a year.

Summary

time.Duration counts nanoseconds, so always write units like 5 * time.Second, and convert int variables with time.Duration(n). Load zones by IANA name, embed time/tzdata for small containers, and store times in UTC. Compare moments with Equal, Before and After, not ==. Measure elapsed time with time.Since, which uses the monotonic clock and ignores system clock changes.