Time and Durations in Go Without Getting Badly Burned
How time.Time and time.Duration work: nanosecond arithmetic, the Duration times int mistake, daylight saving traps, AddDate overflow and comparing times.

Time is where a program that is correct for nine months of the year goes wrong in March. The standard library's time package is well designed, and it still has enough corners that nearly every Go developer has shipped one of the bugs below: a timeout of 5 nanoseconds, a "one day later" that landed an hour off, two identical instants that compared as different. This guide goes through the two types you need, with the arithmetic that is safe and the arithmetic that is not, using output you can reproduce. It builds on integer overflow, because a Duration is an integer, and on type conversion. It is part of the Go basics path, and formatting time with the reference date is its companion.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64), with time/tzdata embedded so the time zone results do not depend on the machine
A Duration is nanoseconds in an int64
time.Duration is defined as type Duration int64, counting nanoseconds. The standard units are constants of that type:
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println(time.Nanosecond, time.Microsecond, time.Millisecond, time.Second, time.Minute, time.Hour)
d := 90 * time.Minute
fmt.Println(d, d.Hours(), d.Minutes(), int64(d))
timeout := 5 * time.Second
fmt.Println(timeout, timeout.Milliseconds())
parsed, err := time.ParseDuration("1h15m30.5s")
fmt.Println(parsed, err)
_, err = time.ParseDuration("90 minutes")
fmt.Println(err)
fmt.Println(time.Duration(1500)*time.Millisecond, time.Duration(1500))
}1ns 1µs 1ms 1s 1m0s 1h0m0s
1h30m0s 1.5 90 5400000000000
5s 5000
1h15m30.5s <nil>
time: unknown unit " minutes" in duration "90 minutes"
1.5s 1.5µsThe String method prints human units, 1h30m0s, and the raw value is nanoseconds, 5400000000000 for ninety minutes. Hours() and Minutes() return floats, and Milliseconds() returns an int64. ParseDuration reads strings like 1h15m30.5s and rejects words, so "90 minutes" is an error: the format is a number and a unit abbreviation, ns, us, ms, s, m, h, with no d for days and no weeks. If you accept a duration from configuration, say in the documentation that it is written like 90m, not 1.5 hours.
The last line shows how the integer nature shows through. time.Duration(1500) is 1,500 nanoseconds, printed as 1.5µs, while time.Duration(1500) * time.Millisecond is 1.5 seconds. A conversion from a plain number to Duration never means seconds, which leads to the commonest bug.
Duration times a variable
An untyped constant can multiply a duration, and a typed variable cannot:
package main
import (
"fmt"
"time"
)
func main() {
seconds := 30
fmt.Println(5 * time.Second)
fmt.Println(time.Second * seconds)
}# example.test/lab
./main.go:11:14: invalid operation: time.Second * seconds (mismatched types time.Duration and int)The first line works because 5 adapts to Duration. The second is a mismatched types error, and the fix is to convert the variable first:
timeout := time.Duration(seconds) * time.SecondThe wrong "fix" is time.Duration(seconds) alone, which compiles and gives a timeout of 30 nanoseconds. Everything that treats such a duration as "expired" will do so immediately, and a retry loop with a 30 nanosecond sleep becomes a busy loop. When you read a number of seconds from a flag or a database, convert once at the boundary and pass a Duration through the rest of the code. Log lines read better if you round a measured duration first. A raw time.Since prints something like 12.345678ms, and d.Round(time.Millisecond) prints 12ms, which is the precision you can trust from a timer on a loaded machine anyway. Round for display, and keep the exact value for any calculation that follows. The same applies to Truncate, which is the better choice when you want to drop the remainder instead of rounding up. The mismatched types article explains the error in the first place.
Multiplying two durations is also a type error to avoid in spirit: 5 * time.Second * time.Second compiles and means 5 times 10^18 nanoseconds, a duration squared, which overflows. If a calculation has two Duration operands multiplied, the units are wrong. The limit is real: the largest Duration is about 292 years, so time.Duration(300 * 365 * 24) * time.Hour wraps.
Time is an instant plus a location
A time.Time records an instant to nanosecond precision and the location used to display it. Arithmetic on instants is through Add and Sub, and the difference of two times is a Duration:
package main
import (
"fmt"
"time"
)
func main() {
start := time.Date(2024, time.January, 15, 9, 30, 0, 0, time.UTC)
end := time.Date(2024, time.January, 15, 17, 45, 30, 0, time.UTC)
elapsed := end.Sub(start)
fmt.Println(elapsed, elapsed.Round(time.Minute))
later := start.Add(36 * time.Hour)
fmt.Println(later.Format(time.RFC3339), later.Weekday())
fmt.Println(start.Before(end), end.After(start), start.Equal(start.Add(0)))
fmt.Println(start.Truncate(time.Hour).Format("15:04"), end.Round(time.Hour).Format("15:04"))
var zero time.Time
fmt.Println(zero.IsZero(), zero.Year(), start.Unix(), start.UnixMilli())
}8h15m30s 8h16m0s
2024-01-16T21:30:00Z Tuesday
true true true
09:00 18:00
true 1 1705311000 1705311000000Sub gives 8h15m30s, and Round snaps a duration to a multiple, here minutes. Add takes a duration. Truncate and Round work on times too, rounding to a multiple of a duration counted from the zero time, which for hours and days is what you want. The zero value of time.Time is January 1 of year 1, UTC, which is valid but is never a real date in your data, so IsZero() is the test for "unset". Do not compare against a literal date. Unix timestamps are available through Unix, UnixMilli and UnixNano, and time.Unix(sec, nsec) goes back, which is the right way to read an integer from a database or a JWT's exp claim, and the Unix timestamp converter is handy for checking one by eye.
Comparing times: Equal, not ==
A Time contains more than an instant. It carries a location pointer, and times read from time.Now() also carry a monotonic clock reading. Two values that represent the same moment can differ in those, so == can say false:
package main
import (
"fmt"
"time"
_ "time/tzdata"
)
func main() {
ny, _ := time.LoadLocation("America/New_York")
utc := time.Date(2024, 6, 1, 16, 0, 0, 0, time.UTC)
local := utc.In(ny)
fmt.Println(utc.Equal(local), utc == local)
fmt.Println(utc.Format(time.RFC3339), local.Format(time.RFC3339))
now := time.Now()
stripped := now.Round(0)
fmt.Println(now.Equal(stripped), now == stripped)
}true false
2024-06-01T16:00:00Z 2024-06-01T12:00:00-04:00
true falseThe same instant, expressed in UTC and in New York, is Equal and not ==, because the locations differ. And a time from time.Now() compared to itself with the monotonic reading stripped by Round(0) is also Equal and not ==. The rule is that Equal, Before, After and Compare compare instants and == compares the whole struct. This also affects time.Time as a map key and in reflect.DeepEqual, in tests that compare structs containing times, which is why go-cmp has a special option for times. Use Equal always. The blank import of time/tzdata embeds the time zone database in the binary, so LoadLocation works in a minimal container that has no /usr/share/zoneinfo, at a cost of about 450 KB.
Daylight saving and AddDate
Adding "a day" has two meanings. An elapsed 24 hours is one. The same wall clock time tomorrow is another, and across a daylight saving change they differ by an hour. New York changed its clocks on March 10, 2024. Starting from noon the day before:
package main
import (
"fmt"
"time"
_ "time/tzdata"
)
func main() {
ny, _ := time.LoadLocation("America/New_York")
t := time.Date(2024, time.March, 9, 12, 0, 0, 0, ny)
fmt.Println(t.Format("Mon 15:04 MST"))
fmt.Println(t.Add(24 * time.Hour).Format("Mon 15:04 MST"))
fmt.Println(t.AddDate(0, 0, 1).Format("Mon 15:04 MST"))
jan31 := time.Date(2024, time.January, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(jan31.AddDate(0, 1, 0).Format("2006-01-02"))
jan31 = time.Date(2023, time.January, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(jan31.AddDate(0, 1, 0).Format("2006-01-02"))
}Sat 12:00 EST
Sun 13:00 EDT
Sun 12:00 EDT
2024-03-02
2023-03-03Add(24 * time.Hour) moved exactly 24 hours of real time and landed on 1 p.m. because the clock jumped forward at 2 a.m. in between. AddDate(0, 0, 1) moved the calendar date and kept the wall clock reading, 12:00. Both are correct and mean different things. For a billing cycle or a reminder at "9 a.m. tomorrow" use AddDate. For an expiry that must be exactly 24 hours of validity, use Add.
AddDate also normalizes dates that do not exist. January 31 plus one month would be February 31, so it rolls over: March 2 in 2024, a leap year, and March 3 in 2023. If you need "last day of next month", add to the first of the month and then step back a day, never add months to the 31st.
Locations, UTC and what to store
Keep instants in UTC inside the program and in storage, and convert to a location when you present them. A time.Time with a location is a display convenience over the same instant, and storing local times without an offset is the beginning of a class of bugs that appear when a user travels or a server changes its time zone. Load locations by IANA name, America/New_York, not by abbreviation, since EST is not unique and does not follow daylight saving. time.Local is whatever the process's TZ setting says, which in a container is often UTC, and in tests is the developer's laptop. For a date with no time of day, such as a birthday, do not store midnight UTC and then convert, because a viewer in an earlier time zone will see the previous day. Store the date as a string 2024-03-09 or as separate year, month and day fields.
Parsing times from text
Times arrive as strings, and time.Parse reads them against a layout written with the reference date, which the format article explains. The detail that matters for correctness is the location. Parse assumes UTC when the string has no zone, and ParseInLocation lets you say what the string means:
package main
import (
"fmt"
"time"
_ "time/tzdata"
)
func main() {
const layout = "2006-01-02 15:04"
asUTC, err := time.Parse(layout, "2024-07-04 09:00")
fmt.Println(asUTC.Format(time.RFC3339), err)
la, _ := time.LoadLocation("America/Los_Angeles")
asLA, err := time.ParseInLocation(layout, "2024-07-04 09:00", la)
fmt.Println(asLA.Format(time.RFC3339), err)
fmt.Println(asLA.UTC().Format(time.RFC3339), asUTC.Equal(asLA))
_, err = time.Parse(layout, "2024-13-01 09:00")
fmt.Println(err)
}2024-07-04T09:00:00Z <nil>
2024-07-04T09:00:00-07:00 <nil>
2024-07-04T16:00:00Z false
parsing time "2024-13-01 09:00": month out of rangeThe same text produced two different instants, seven hours apart in July, depending on the location you attached. If you store only the string, you are storing an ambiguity. The error for an invalid month names the field that is out of range, and a wrong layout gives a message that quotes both, which makes the cause easy to see. Always put the zone in the string you store, with RFC 3339 and its trailing Z or offset, and parse it with time.RFC3339.
Deadlines and the context package
A Duration is also how you tell the rest of the program how long something may take. context.WithTimeout turns a duration into a deadline, and anything that respects the context stops when it passes:
package main
import (
"context"
"fmt"
"time"
)
func slowCall(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
fmt.Println(slowCall(ctx, 5*time.Millisecond))
fmt.Println(slowCall(ctx, 200*time.Millisecond))
deadline, ok := ctx.Deadline()
fmt.Println(ok, time.Until(deadline) <= 0)
}<nil>
context deadline exceeded
true trueThe fast call finished inside the deadline and returned nil. The slow one lost the race and returned context deadline exceeded, which is the error that tracking down a deadline exceeded and context cancellation in web services go into. ctx.Deadline() returns the instant the context expires, and time.Until turns it into a remaining duration, here negative because the deadline had already passed. Choosing the timeout is a design decision: a constant in code is a guess, and a value from configuration with a documented default is better.
A worked problem: the next 9 a.m. in a city
Scheduling code has to answer "when is the next occurrence of 09:00 in this location", and the correct answer depends on daylight saving, so it is a good test of the rules above:
package main
import (
"fmt"
"time"
_ "time/tzdata"
)
func nextAt(from time.Time, hour int, loc *time.Location) time.Time {
local := from.In(loc)
candidate := time.Date(local.Year(), local.Month(), local.Day(), hour, 0, 0, 0, loc)
if !candidate.After(from) {
candidate = time.Date(local.Year(), local.Month(), local.Day()+1, hour, 0, 0, 0, loc)
}
return candidate
}
func main() {
ny, _ := time.LoadLocation("America/New_York")
before := time.Date(2024, time.March, 9, 14, 30, 0, 0, ny)
next := nextAt(before, 9, ny)
fmt.Println(next.Format("Mon 2006-01-02 15:04 MST"), next.Sub(before))
gap := time.Date(2024, time.March, 9, 9, 0, 0, 0, ny)
fmt.Println(nextAt(gap, 9, ny).Format("Mon 2006-01-02 15:04 MST"), nextAt(gap, 9, ny).Sub(gap))
}Sun 2024-03-10 09:00 EDT 17h30m0s
Sun 2024-03-10 09:00 EDT 23h0m0sThe function builds the candidate with time.Date, which takes a wall clock time in a location and returns the right instant, and moves to the next day with the Day()+1 argument, which Date normalizes. It does not add 24 * time.Hour. The result for the second case crosses the March 10 change: the interval from 9 a.m. Saturday to 9 a.m. Sunday is 23 hours of real time, which the printed subtraction shows. A version that added 24 hours would schedule the job at 10 a.m. on Sunday. One edge remains: a wall clock time that does not exist, such as 2:30 a.m. on the spring-forward night, is resolved by Date to a nearby valid instant, and one that happens twice in the autumn is resolved to the first. If exactness matters at those moments, test them explicitly.
Measuring elapsed time
For timing a piece of code, time.Now() and time.Since(start) are right. The Time returned by Now includes a monotonic clock reading, and Since and Sub use it when both operands have one, so a wall clock adjustment, such as an NTP step or a manual change, cannot make your measurement negative. If you store a start time in a database and compare it after a restart, the monotonic reading is gone, and you are comparing wall clocks, which can move. That is the correct trade-off, and the reason "elapsed since process start" and "elapsed since created_at" are different problems. For benchmarks use testing.B, not time.Since.
JSON, tickers and tests
A few related behaviors you will meet quickly. time.Time marshals to JSON as an RFC 3339 string, and a Duration marshals as a number of nanoseconds, which a JavaScript client will find surprising. Timers and tickers have their own articles, tickers without leaks and the time.After leak and the Go 1.23 timer changes. For testing code that depends on the current time, hold a now func() time.Time field and replace it in tests, as in the function field pattern in functions in Go, and avoid time.Sleep in tests because it turns a deterministic test into a slow one.
Where this advice stops
The standard library has no calendar beyond the Gregorian one, no business-day arithmetic, no recurrence rules and no time zone rules newer than the database you embed, so scheduling that must follow a country's changing rules needs either regular updates of the embedded data or the operating system's database. A handful of programs, such as astronomy or financial systems that need leap seconds, require a different model: Go ignores leap seconds. And for cron-like schedules in a specific zone, rely on a tested scheduling library and confirm its daylight saving behavior. The authoritative references are the time package documentation, including its long section on monotonic clocks, and the IANA time zone database that Go's time/tzdata embeds.


