Time and Timers

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. Package time is two libraries in one. The first measures and represents time: the Duration type for a length of time, the Time type for an instant, Location for time zones, and the layout-based formatting and parsing. The second waits: Sleep, Timer, Ticker, After, AfterFunc and Tick, which turn a point in the future into a channel receive or a function call. This page covers both, the 1.23 rewrite of timers that made Stop and Reset safe to use and the removal of its opt-out switch in 1.27, and how to test code that depends on the clock without waiting for it.

It relies on Channels and Select (every timer delivers its event on a channel), Goroutines and the Scheduler and Functions, Closures and defer. The related pages are Context (deadlines and timeouts that cross function boundaries), Concurrency Patterns (rate limiting, heartbeats and retries), Benchmarks, Fuzzing and synctest (the full treatment of testing/synctest), encoding/json, Structured Logging with slog and net/http Clients and Servers (server and client timeouts). Other languages' time libraries are covered in Java dates and times (java.time, whose type-per-concept design is the opposite of Go’s single Time), C++ chrono, C dates and times, PHP date and time and JavaScript dates.

Every example was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output (compiler and vet messages are copied from real runs). Examples whose result would depend on timing wait on channels, use generous margins, and print only facts that do not vary between runs; the one example that prints a clock reading (time.Now) prints only properties of it. The go.mod of the example module is:

module example.com/shelf

go 1.27

The time package at a glance

Concept Types and functions Used for

A length of time

Duration, ParseDuration, Since, Until, constants Nanosecond to Hour

Loan periods, timeouts, intervals, measuring elapsed time

An instant

Time, Now, Date, Unix, UnixMilli, methods Add, Sub, Before, After, Equal, Compare

Due dates, timestamps, ordering events

Calendar arithmetic and fields

AddDate, Year, Month, Day, Weekday, YearDay, ISOWeek, Truncate, Round

"Due in three weeks", "start of the day", "which week is it"

Time zones

Location, LoadLocation, FixedZone, UTC, Local, In, Zone, ZoneBounds, IsDST

Showing a time to a person in their zone, branch opening hours

Text

Format, AppendFormat, Parse, ParseInLocation, layout constants, MarshalJSON, MarshalText

Logs, APIs, files, user input

Waiting

Sleep, Timer, NewTimer, AfterFunc, After, Ticker, NewTicker, Tick

Timeouts, retries with backoff, periodic jobs, expiring holds

Test support

testing/synctest (fake clock), injected func() time.Time clocks

Fast, deterministic tests of timeouts and tickers

Duration: a length of time

A time.Duration is an int64 counting nanoseconds. That makes it exact, cheap and comparable with <, and it limits the range to about 292 years. It is a defined type with methods, and the package provides constants for the units, so that a duration is written as a number times a unit:

Constant Value

time.Nanosecond

1

time.Microsecond

1000 nanoseconds

time.Millisecond

1000 microseconds

time.Second

1000 milliseconds

time.Minute

60 seconds

time.Hour

60 minutes

There is no Day, Week, Month or Year constant, on purpose: a day is not always 24 hours (see daylight saving) and a month has no fixed length. A period measured in calendar units is added to a Time with AddDate, not expressed as a Duration. The String method prints the largest unit that fits (1h30m0s, 1.5s, 250ms), and ParseDuration reads the same notation with the units ns, us (or µs), ms, s, m and h, each number optionally fractional and signed.

package main

import (
	"fmt"
	"time"
)

func main() {
	// A loan period is a Duration: an int64 count of nanoseconds.
	loan := 21 * 24 * time.Hour
	grace := 90 * time.Minute
	fmt.Println(loan, grace, loan+grace)
	fmt.Printf("%d %T\n", time.Second, time.Second)

	// Accessors return float64 (Seconds, Minutes, Hours) or int64.
	fmt.Println(loan.Hours(), grace.Minutes(), grace.Milliseconds())

	// Parsing and printing.
	d, err := time.ParseDuration("1h15m30.5s")
	fmt.Println(d, err)
	_, err = time.ParseDuration("3 days")
	fmt.Println(err)

	// Round and Truncate round to a multiple; Abs (1.19) is safe at MinInt64.
	fmt.Println(d.Round(time.Minute), d.Truncate(time.Hour), (-d).Abs())

	// Multiply a Duration by a count only after converting the count.
	n := 3
	fmt.Println(time.Duration(n)*time.Second, 1500*time.Millisecond)

	// Duration arithmetic wraps around silently on overflow.
	big := time.Duration(1<<63 - 1)
	fmt.Println(big, big+1)
}
504h0m0s 1h30m0s 505h30m0s
1000000000 time.Duration
504 90 5400000
1h15m30.5s <nil>
time: unknown unit " days" in duration "3 days"
1h16m0s 1h0m0s 1h15m30.5s
3s 1.5s
2562047h47m16.854775807s -2562047h47m16.854775808s

Source: time.Duration

A few details in the output deserve a closer look.

  • Hours, Minutes and Seconds return float64; Milliseconds, Microseconds and Nanoseconds return int64. Use the integer ones for exact values.

  • Round and Truncate round a duration to a multiple of m; halfway values round away from zero. Abs (Go 1.19) returns the absolute value and, unlike negating by hand, saturates at the minimum value instead of overflowing.

  • ParseDuration accepts no days (3 days, or 1d): the error names the unit it could not read.

  • A Duration converts to a number (int64(d)) and back, and prints with %d as raw nanoseconds, with %v and %s through String.

  • Arithmetic on Duration is plain int64 arithmetic and wraps around on overflow, as the last line shows. Time.Sub is the exception: it saturates at the largest and smallest durations.

Multiplying a duration by a number

The constants are typed constants of type Duration, and Go has no implicit conversions. Two mistakes follow, and both are rejected by the compiler:

package main

import (
	"fmt"
	"time"
)

func main() {
	n := 3
	fmt.Println(n * time.Second)
	fmt.Println(1.5 * time.Second)
}
./main.go:10:14: invalid operation: n * time.Second (mismatched types int and time.Duration)
./main.go:11:14: 1.5 (untyped float constant) truncated to int64

The first is the most common compile error with this package: n * time.Second where n is an int variable. Convert the count: time.Duration(n) * time.Second. The second looks harmless and is not: 1.5 is converted to the type of time.Second, Duration, which is an integer type, and an untyped constant that cannot be represented exactly is an error. Write 1500 * time.Millisecond, or time.Duration(1.5 * float64(time.Second)) when the factor is computed at run time. See Variables, Constants and iota for the rules of typed and untyped constants.

Time: an instant, with two clocks

A time.Time represents an instant with nanosecond precision, together with a Location used when the instant is shown as a calendar date. It is a small value type (a wall-clock reading, an optional monotonic reading and a location pointer), meant to be copied and passed by value, never by pointer. It is safe to share between goroutines because no method mutates it.

The zero value, time.Time{}, is January 1 of year 1, 00:00:00 UTC. It is not the Unix epoch, and IsZero is the correct way to ask "was this field ever set?" (a Time.Unix() of 0 is the year 1970, not an unset value).

Wall clock and monotonic clock

time.Now() returns two readings of the clock. The wall clock is the calendar time of day; it is what Format prints, and it can jump: an administrator or NTP changes it, a virtual machine resumes, or a leap second is smeared. The monotonic clock only moves forward at a steady rate and is meant for measuring intervals. A Time made by Now carries both; when two Time values both carry a monotonic reading, Sub, Since, Until, Before, After, Equal and Compare use it and ignore the wall clock. A program that measures time.Since(start) therefore keeps working across a clock change, which is the behaviour the older time API of other languages makes you ask for explicitly.

The monotonic reading is a property of the value. Add keeps it, and every operation whose purpose is to change how the wall time is read strips it: Round and Truncate (with any argument), UTC, Local and In. Values built by Date, Parse and Unix never have one. Round(0) is the idiom to strip it on purpose, for example before comparing with == or before storing the value. The monotonic reading shows in String() as m=+0.000012345; do not parse it, and do not rely on it surviving serialisation.

package main

import (
	"fmt"
	"strings"
	"time"
)

func main() {
	// Now carries a wall-clock reading and a monotonic reading.
	start := time.Now()
	fmt.Println("Now:      ", hasMono(start))

	// Add keeps the reading; Round(0), UTC, Local and In strip it.
	fmt.Println("Add:      ", hasMono(start.Add(time.Hour)))
	fmt.Println("Round(0): ", hasMono(start.Round(0)))
	fmt.Println("UTC:      ", hasMono(start.UTC()))

	// Equal compares instants; == also compares location pointer and monotonic reading.
	a := time.Date(2026, time.October, 18, 12, 0, 0, 0, time.UTC)
	cet := time.FixedZone("CET", 3600)
	b := a.In(cet)
	fmt.Println("a == b:       ", a == b)
	fmt.Println("a.Equal(b):   ", a.Equal(b))
	fmt.Println("a.Compare(b): ", a.Compare(b))
	fmt.Println("a.Before(b.Add(time.Nanosecond)):", a.Before(b.Add(time.Nanosecond)))

	// Zero value: January 1, year 1, 00:00:00 UTC.
	var zero time.Time
	fmt.Println(zero, zero.IsZero(), zero.Unix())

	// Sub, Since, Until.
	due := a.AddDate(0, 0, 21)
	fmt.Println(due.Sub(a), due.Format(time.DateOnly), due.Weekday(), due.YearDay())
	y, w := due.ISOWeek()
	fmt.Println(y, w)

	// Time values from and to Unix timestamps.
	u := time.Unix(1_790_000_000, 0).UTC()
	fmt.Println(u, u.UnixMilli(), time.UnixMilli(u.UnixMilli()).UTC().Equal(u))
}

// hasMono reports whether t carries a monotonic clock reading: String
// appends it as "m=+0.000012345".
func hasMono(t time.Time) bool { return strings.Contains(t.String(), " m=") }
Now:       true
Add:       true
Round(0):  false
UTC:       false
a == b:        false
a.Equal(b):    true
a.Compare(b):  0
a.Before(b.Add(time.Nanosecond)): true
0001-01-01 00:00:00 +0000 UTC true -62135596800
504h0m0s 2026-11-08 Sunday 312
2026 45
2026-09-21 14:13:20 +0000 UTC 1790000000000 true

Comparing instants: Equal, Compare and ==

Three things look like equality and are not:

  • t1.Equal(t2) is true when both are the same instant, whatever their locations. This is the comparison to use.

  • t1 == t2 compares the struct, including the location pointer and the monotonic reading, so the same instant in UTC and in FixedZone("CET", 3600) is not ==. The output above prints a == b: false and a.Equal(b): true for the same instant. It is wrong in almost every program, and reflect.DeepEqual has the same problem because it compares the fields.

  • t1.Compare(t2) (Go 1.20) returns -1, 0 or +1 and is meant for sorting: slices.SortFunc(loans, func(a, b Loan) int { return a.Due.Compare(b.Due) }). Before and After are the boolean forms.

Because of the location pointer, a time.Time is a poor map key; the next sections show a fix. Time has no < or >, which is why there are methods.

Calendar fields and Unix timestamps

Year, Month (a time.Month, printed as October and numbered from 1), Day, Hour, Minute, Second, Nanosecond, Weekday (a time.Weekday, Sunday is 0), YearDay, ISOWeek and Date()/Clock() (all three fields at once) read the date in the value’s own location. Unix, UnixMilli, UnixMicro and UnixNano return the integer count since 1970-01-01 UTC (UnixNano is undefined for dates outside the years 1678 to 2262), and time.Unix(sec, nsec), time.UnixMilli and time.UnixMicro build a Time from them; the result is in the local time zone, so call .UTC() before printing a timestamp.

Truncate(d) and Round(d) round an instant to a multiple of d counted from the zero time in UTC. For a day that is midnight UTC, not midnight in the value’s location, so t.Truncate(24 * time.Hour) is the wrong way to find the start of the local day; build it with time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()). The last example on this page shows the difference.

Time zones and Location

A *time.Location is a set of rules that maps an instant to an offset from UTC and an abbreviation (EST, CET). A Time holds one, and t.In(loc) returns the same instant presented in loc (the value and its monotonic reading are unchanged apart from the stripping described above). Three locations are always available:

  • time.UTC: no offset, no rules.

  • time.Local: the zone of the machine, from the TZ environment variable or the system’s configuration. Servers usually run in UTC, laptops do not; code that works in Local therefore behaves differently in development and in production. Store and exchange instants in UTC (or as Unix timestamps) and convert to a zone only to show them.

  • a time.FixedZone(name, offsetSeconds): a constant offset with no daylight saving, for a zone you know only by its offset.

time.LoadLocation("America/New_York") loads a zone with full history from the IANA time zone database. It searches, in order: the directory or uncompressed zip file named by the ZONEINFO environment variable, the system’s installation (/usr/share/zoneinfo on Unix), $GOROOT/lib/time/zoneinfo.zip, and the copy embedded in the binary if the program imports time/tzdata (or is built with -tags timetzdata). The embedded copy adds about 450 KB; import it from the main package of programs that run in minimal containers (a scratch or distroless image has no zone files), not from libraries. LoadLocation("") and LoadLocation("UTC") return UTC, LoadLocation("Local") returns Local, and an unknown name is an error, as the first lines of the output show.

package main

import (
	"fmt"
	"time"
)

func main() {
	ny, err := time.LoadLocation("America/New_York")
	if err != nil {
		fmt.Println(err)
		return
	}
	_, err = time.LoadLocation("Mars/Olympus_Mons")
	fmt.Println(err)

	// A branch closes at 18:00 local time, whatever the offset is that day.
	closing := time.Date(2026, time.March, 7, 18, 0, 0, 0, ny)
	fmt.Println(closing)

	// AddDate works on the calendar; Add works on the clock.
	fmt.Println(closing.AddDate(0, 0, 1))              // next day, still 18:00 local
	fmt.Println(closing.Add(24 * time.Hour))           // 24 hours later: 19:00 EDT
	fmt.Println(closing.AddDate(0, 0, 1).Sub(closing)) // only 23 hours apart

	// A wall time that does not exist is normalised; one that happens
	// twice picks one of the two instants.
	gap := time.Date(2026, time.March, 8, 2, 30, 0, 0, ny)
	fmt.Println("gap: ", gap)
	twice := time.Date(2026, time.November, 1, 1, 30, 0, 0, ny)
	fmt.Println("twice:", twice, twice.IsDST())

	// Zone, ZoneBounds (1.19) and In.
	name, offset := closing.Zone()
	start, end := closing.ZoneBounds()
	fmt.Println(name, offset, start, end)
	fmt.Println(closing.In(time.UTC), closing.UTC().Location(), time.Local != nil)

	// A fixed offset has no daylight saving rules.
	ist := time.FixedZone("IST", 5*3600+30*60)
	fmt.Println(closing.In(ist).Format(time.RFC1123Z))

	// Month overflow is normalised, not rejected.
	fmt.Println(time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0).Format(time.DateOnly))
	fmt.Println(time.Date(2026, time.February, 30, 25, 61, 0, 0, time.UTC))
}
unknown time zone Mars/Olympus_Mons
2026-03-07 18:00:00 -0500 EST
2026-03-08 18:00:00 -0400 EDT
2026-03-08 19:00:00 -0400 EDT
23h0m0s
gap:  2026-03-08 01:30:00 -0500 EST
twice: 2026-11-01 01:30:00 -0400 EDT true
EST -18000 2025-11-02 01:00:00 -0500 EST 2026-03-08 03:00:00 -0400 EDT
2026-03-07 23:00:00 +0000 UTC UTC true
Sun, 08 Mar 2026 04:30:00 +0530
2026-03-03
2026-03-03 02:01:00 +0000 UTC

Source: time.Location

What this output teaches:

  • AddDate(0, 0, 1) moves the calendar by one day and keeps the local time of day (18:00), so across the March daylight saving change the two instants are only 23 hours apart. Add(24 * time.Hour) moves the clock by 24 hours and lands at 19:00 EDT. For "the book is due in three weeks" use AddDate(0, 0, 21); for "the hold expires in 48 hours" use Add(48 * time.Hour).

  • A local time that does not exist (02:30 on the spring-forward day) or that happens twice (01:30 on the fall-back day) is resolved by Date without an error, and the documentation says the choice between the two possible results is not guaranteed. Code that must be exact near a change represents the instant in UTC or uses the offset (-0500).

  • Zone() returns the abbreviation and offset in effect, ZoneBounds() (Go 1.19) the start and end of that period (the zero Time when unbounded), and IsDST() (Go 1.17) whether daylight saving is in effect. Abbreviations are not unique (IST is Indian, Irish and Israel Standard Time) and must not be parsed back for a zone.

  • Date normalises out-of-range fields instead of rejecting them: month 13 becomes January of the next year, January 31 + 1 month becomes March 3 (February 31 does not exist), and February 30, 25:61 carries over into March. Validate user-supplied dates by comparing the fields you passed with the fields of the result.

Source: time/tzdata

Formatting and parsing: the reference time

Go formats times with an example instead of with codes. A layout is the reference time

Mon Jan 2 15:04:05 MST 2006

(which is also 01/02 03:04:05PM '06 -0700: month 1, day 2, hour 3 or 15, minute 4, second 5, year 2006, zone offset -7 hours) written the way the output should look. Format replaces each recognised element of the layout with the matching field of the time; Parse reads a string with the same layout. The reference time was chosen so that each element has a different number, which is how the parser tells 01 (month) from 02 (day) from 04 (minute).

package main

import (
	"fmt"
	"time"
)

func main() {
	t := time.Date(2026, time.October, 18, 15, 4, 5, 123456789, time.UTC)

	// The layout is the reference time written the way you want the output.
	// Mon Jan 2 15:04:05 MST 2006 is 01/02 03:04:05PM '06 -0700.
	layouts := []struct{ name, layout string }{
		{"DateTime", time.DateTime},
		{"DateOnly", time.DateOnly},
		{"TimeOnly", time.TimeOnly},
		{"RFC3339", time.RFC3339},
		{"RFC3339Nano", time.RFC3339Nano},
		{"RFC1123", time.RFC1123},
		{"Kitchen", time.Kitchen},
		{"custom", "Mon 2 Jan 2006, 3:04pm"},
		{"millis", "2006-01-02 15:04:05.000"},
		{"trimmed fraction", "15:04:05.999"},
	}
	for _, l := range layouts {
		fmt.Printf("%-16s %s\n", l.name, t.Format(l.layout))
	}

	// Parse reads the same layouts; a missing zone means UTC.
	p, err := time.Parse(time.DateTime, "2026-11-08 09:30:00")
	fmt.Println(p, err)

	// ParseInLocation interprets an offset-less time in a given location.
	ny, _ := time.LoadLocation("America/New_York")
	q, err := time.ParseInLocation(time.DateTime, "2026-11-08 09:30:00", ny)
	fmt.Println(q, err)

	// Errors are *time.ParseError values.
	_, err = time.Parse(time.DateOnly, "18/10/2026")
	fmt.Println(err)
	_, err = time.Parse(time.DateOnly, "2026-02-30")
	fmt.Println(err)

	// The classic trap: 2016 is not a year in the reference time.
	fmt.Println(t.Format("2016-01-02"))
	fmt.Println(t.Format("2006-01-02"))

	// AppendFormat avoids an allocation when building log lines.
	buf := []byte("due=")
	buf = t.AppendFormat(buf, time.DateOnly)
	fmt.Println(string(buf))
}
DateTime         2026-10-18 15:04:05
DateOnly         2026-10-18
TimeOnly         15:04:05
RFC3339          2026-10-18T15:04:05Z
RFC3339Nano      2026-10-18T15:04:05.123456789Z
RFC1123          Sun, 18 Oct 2026 15:04:05 UTC
Kitchen          3:04PM
custom           Sun 18 Oct 2026, 3:04pm
millis           2026-10-18 15:04:05.123
trimmed fraction 15:04:05.123
2026-11-08 09:30:00 +0000 UTC <nil>
2026-11-08 09:30:00 -0500 EST <nil>
parsing time "18/10/2026" as "2006-01-02": cannot parse "18/10/2026" as "2006"
parsing time "2026-02-30": day out of range
18106-10-18
2026-10-18
due=2026-10-18

The elements, shown by formatting 2026-03-05 07:08:09.12 in America/New_York:

Field Layout element Output Notes

Year

2006, 06

2026, 26

Four digits or two.

Month

January, Jan, 01, 1

March, Mar, 03, 3

Name, abbreviation, zero-padded number, number.

Weekday

Monday, Mon

Thursday, Thu

Day of month

02, _2, 2

05, " 5", 5

_2 pads with a space.

Day of year

002, __2

064, " 64"

Zero- or space-padded.

Hour

15, 03, 3

07, 07, 7

15 is the 24-hour clock; 03 and 3 are the 12-hour clock.

Minute

04, 4

08, 8

Second

05, 5

09, 9

AM or PM

PM, pm

AM, am

Needs a 12-hour element to be meaningful.

Fraction

.000, .999, ,000

.120, .12, ,120

Zeros keep trailing zeros, nines trim them; a fixed number of digits; a comma is accepted as the separator.

Zone abbreviation

MST

EST

Zone offset

-0700, -07:00, -07

-0500, -05:00, -05

Zone offset, Z for UTC

Z0700, Z07:00

-0500, -05:00

Prints Z when the offset is zero.

The package defines the common layouts as constants; prefer them to hand-written strings.

Constant Layout

time.DateTime (1.20)

2006-01-02 15:04:05

time.DateOnly (1.20)

2006-01-02

time.TimeOnly (1.20)

15:04:05

time.RFC3339

2006-01-02T15:04:05Z07:00

time.RFC3339Nano

2006-01-02T15:04:05.999999999Z07:00

time.RFC1123, time.RFC1123Z

Mon, 02 Jan 2006 15:04:05 MST, and the same with a numeric zone

time.RFC822, time.RFC822Z, time.RFC850

02 Jan 06 15:04 MST, 02 Jan 06 15:04 -0700, Monday, 02-Jan-06 15:04:05 MST

time.ANSIC, time.UnixDate, time.RubyDate

Mon Jan _2 15:04:05 2006, Mon Jan _2 15:04:05 MST 2006, Mon Jan 02 15:04:05 -0700 2006

time.Kitchen

3:04PM

time.Stamp, time.StampMilli, time.StampMicro, time.StampNano

Jan _2 15:04:05 and the same with 3, 6 or 9 fraction digits

time.Layout

01/02 03:04:05PM '06 -0700, the reference time in numerical order

Parsing, locations and errors

  • time.Parse(layout, s) returns a time in UTC when s has no zone information, and in a made-up location with the given abbreviation and a zero offset when it has an abbreviation that the process’s Local does not know. Prefer numeric offsets in anything you control.

  • time.ParseInLocation(layout, s, loc) is the correct call for a string such as 2026-11-08 09:30:00 that means "9:30 in this branch’s zone"; the example shows the result in America/New_York (-0500 EST, because daylight saving has ended by that date).

  • Failures are *time.ParseError values with the layout, the value and the element that failed. Beyond a wrong layout they include a day that does not exist (2026-02-30 is day out of range); unlike Date, Parse validates.

  • Leap seconds and :60 are not supported, and a seconds field may be followed by a fraction in the input even if the layout has none.

The classic layout mistake

The layout must be built from the reference numbers, not from today’s date. t.Format("2016-01-02") is not a year 2016 layout: 2006 is the year element and 2016 is not, so the digits are read as other elements and the result is garbage such as 18106-10-18. Layouts borrowed from other languages fail silently too: YYYY-MM-DD HH:mm contains no recognised element and is copied to the output unchanged (the check run for this page printed it back verbatim). When a time prints strangely, check the layout against time.Layout. The output of the example shows the failing and the correct form side by side.

Source: time: constants

AppendFormat writes into an existing byte slice and is the allocation-free choice for log lines and hot paths. Time also implements fmt.Stringer (String uses 2006-01-02 15:04:05.999999999 -0700 MST plus the monotonic reading), fmt.GoStringer, and encoding.TextMarshaler/TextUnmarshaler and json.Marshaler/Unmarshaler with RFC 3339.

Time and Duration in JSON, maps and comparisons

Time marshals as an RFC 3339 string with nanoseconds, in the value’s own offset, and unmarshals only that format; Duration has no special treatment and marshals as an integer count of nanoseconds, which is rarely what an API wants. The next program shows both, the map key problem from the comparison section, and the Truncate surprise.

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Loan struct {
	Member string        `json:"member"`
	Due    time.Time     `json:"due"`
	Grace  time.Duration `json:"grace"`
}

func main() {
	// Time marshals as RFC 3339 with nanoseconds; Duration as a bare integer.
	est := time.FixedZone("EST", -5*3600)
	l := Loan{"ada", time.Date(2026, time.October, 18, 9, 0, 0, 0, est), 90 * time.Minute}
	b, _ := json.Marshal(l)
	fmt.Println(string(b))

	var back Loan
	fmt.Println(json.Unmarshal(b, &back), back.Due.Equal(l.Due), back.Due == l.Due)

	var bad Loan
	fmt.Println(json.Unmarshal([]byte(`{"due":"2026-10-18"}`), &bad))

	// == on Time compares the location pointer too, so it is a poor map key:
	utc := time.Date(2026, time.October, 18, 14, 0, 0, 0, time.UTC)
	seen := map[time.Time]string{utc: "UTC"}
	_, ok := seen[utc.In(est)]
	fmt.Println("same instant found via map key:", ok)
	byUnix := map[int64]string{utc.UnixNano(): "UTC"}
	_, ok = byUnix[utc.In(est).UnixNano()]
	fmt.Println("same instant found via UnixNano:", ok)

	// Truncate rounds down relative to the zero time (UTC), not to local midnight.
	ist := time.FixedZone("IST", 5*3600+30*60)
	t := time.Date(2026, time.October, 18, 10, 0, 0, 0, ist)
	fmt.Println(t.Truncate(24*time.Hour), "vs", time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()))

	// Sub saturates at the Duration limits instead of wrapping.
	fmt.Println(time.Time{}.Sub(time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC)) == time.Duration(-1<<63))
}
{"member":"ada","due":"2026-10-18T09:00:00-05:00","grace":5400000000000}
<nil> true false
parsing time "2026-10-18" as "2006-01-02T15:04:05Z07:00": cannot parse "" as "T"
same instant found via map key: false
same instant found via UnixNano: true
2026-10-18 05:30:00 +0530 IST vs 2026-10-18 00:00:00 +0530 IST
true
  • After a JSON round trip the instant is Equal to the original and, because the decoded value has a different *Location, it is not ==. Compare with Equal.

  • {"due":"2026-10-18"} fails because only RFC 3339 is accepted. For dates-only fields define a type with its own UnmarshalJSON using time.DateOnly, or decode into a string. The page encoding/json shows custom marshalers and the encoding/json/v2 formatting options (v2 can write durations as strings or as nanoseconds).

  • A Time as a map key distinguishes the zones and the monotonic reading: the same instant in EST is a different key. Use t.UnixNano() (or t.UTC().Round(0) when you need a Time) as the key.

  • Truncation to a day boundary must use time.Date with the value’s location, as the output above demonstrates: Truncate(24 * time.Hour) returned 05:30 IST, which is midnight UTC.

Sleeping, waiting and timers

time.Sleep(d) blocks the current goroutine for at least d; a non-positive d returns immediately. Sleeping is cheap (the goroutine is parked, see Goroutines and the Scheduler) but cannot be interrupted, so production code that must stop early waits on a channel or a context instead.

Type or function Fires Delivers

time.NewTimer(d) / *Timer

once, after d

the time on t.C; Stop and Reset control it

time.After(d)

once, after d

NewTimer(d).C: the channel only, no way to stop it

time.AfterFunc(d, f)

once, after d

runs f in its own goroutine; t.C is nil; Stop cancels it

time.NewTicker(d) / *Ticker

every d until stopped; panics when d <= 0

the time on tk.C; Reset changes the period; slow receivers lose ticks

time.Tick(d)

every d

NewTicker(d).C only; returns nil when d <= 0

A timer’s channel carries the time at which the timer fired. The channel is never closed: a timer is not a signal that a goroutine can range over, and <-t.C blocks forever on a stopped timer. The delivery is a single send of one value (see the corrections at the end of this page).

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// fetch stands in for a slow call; it returns when work is done.
func fetch(delay time.Duration) <-chan string {
	ch := make(chan string, 1) // buffered: the sender never blocks if nobody listens
	go func() {
		time.Sleep(delay)
		ch <- "catalogue page"
	}()
	return ch
}

func main() {
	// 1. time.After in a select: a one-shot timeout.
	select {
	case v := <-fetch(10 * time.Millisecond):
		fmt.Println("got", v)
	case <-time.After(time.Second):
		fmt.Println("timeout")
	}
	select {
	case v := <-fetch(time.Second):
		fmt.Println("got", v)
	case <-time.After(20 * time.Millisecond):
		fmt.Println("timeout")
	}

	// 2. Ticker: periodic work. Stop it when finished.
	tk := time.NewTicker(10 * time.Millisecond)
	defer tk.Stop()
	for i := 1; i <= 3; i++ {
		<-tk.C
		fmt.Println("tick", i)
	}
	tk.Reset(5 * time.Millisecond) // change the period (1.15)

	// 3. AfterFunc: run f in its own goroutine after d. No channel.
	done := make(chan struct{})
	hold := time.AfterFunc(20*time.Millisecond, func() {
		fmt.Println("hold expired")
		close(done)
	})
	<-done
	fmt.Println("Stop after run:", hold.Stop())

	// 4. A cancelled AfterFunc never runs.
	never := time.AfterFunc(time.Hour, func() { fmt.Println("never printed") })
	fmt.Println("Stop before run:", never.Stop())

	// 5. Prefer a context for deadlines that cross function boundaries.
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	select {
	case <-fetch(time.Second):
	case <-ctx.Done():
		fmt.Println(ctx.Err(), errors.Is(ctx.Err(), context.DeadlineExceeded))
	}
}
got catalogue page
timeout
tick 1
tick 2
tick 3
hold expired
Stop after run: false
Stop before run: true
context deadline exceeded true

Source: time.NewTimer

Reading the example:

  • The first select uses time.After as a one-shot timeout. The fetch helper’s channel is buffered (capacity 1) so the abandoned goroutine in the timeout case can finish its send and exit; with an unbuffered channel it would block forever and leak (see Concurrency Patterns).

  • Ticker sends on its channel at a fixed rate and drops ticks for a receiver that is slower than the period instead of queuing them. Always Stop a ticker when you are done with it in a long-running program: although the garbage collector can reclaim it since Go 1.23, stopping it releases the runtime timer immediately and states the intent.

  • AfterFunc runs f on its own goroutine. Stop returns false once f has started (not once it has finished); it does not wait for f. If the caller needs to know that f is complete it must coordinate, for example with a channel as the example’s done. A stopped AfterFunc never runs.

  • For a deadline that crosses function boundaries, context.WithTimeout is better than a hand-passed timer: it composes with parent cancellation and shows up in ctx.Err(). The last select uses it. See Context.

Source: time.AfterFunc

The timer changes of Go 1.23

Before Go 1.23 timers had two sharp edges that generated years of bug reports.

  1. Timers were not garbage collected until they fired. A time.After(time.Hour) in a loop leaked an hour-long timer per iteration, and every time.NewTimer was followed by a defer t.Stop() to avoid it, while time.Tick was documented as leaking its ticker forever.

  2. The timer channel was buffered (capacity 1). A timer that fired while the program was busy left a value in the channel, and a later Stop or Reset could not remove it. The code that followed the rules read like this: if !t.Stop() { <-t.C } before every Reset, which itself deadlocks when the channel was already drained.

Go 1.23 changed both, and the release notes state the effect: timers and tickers that are no longer referenced become eligible for garbage collection immediately, and the channel is now synchronous (capacity 0), so the runtime guarantees that after Stop or Reset returns no stale value will be received. The program below checks each guarantee on Go 1.27.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Go 1.23+: the channel of a Timer or Ticker is synchronous.
	t := time.NewTimer(10 * time.Millisecond)
	fmt.Println("len, cap:", len(t.C), cap(t.C))

	// Let the timer expire without receiving from it.
	time.Sleep(50 * time.Millisecond)
	fmt.Println("len after expiry:", len(t.C))

	// Stop reports true: no value was delivered, and none will be.
	fmt.Println("Stop:", t.Stop())
	select {
	case v := <-t.C:
		fmt.Println("stale value:", v.IsZero())
	default:
		fmt.Println("no stale value after Stop")
	}

	// Reset after expiry is just as safe: no draining loop is needed.
	t.Reset(20 * time.Millisecond)
	start := time.Now()
	<-t.C
	fmt.Println("fired after Reset:", time.Since(start) >= 20*time.Millisecond)

	// After the value was received, Stop returns false.
	fmt.Println("Stop after receive:", t.Stop())

	// An unreferenced, unexpired timer is garbage collected; no defer t.Stop().
	for range 3 {
		_ = time.NewTimer(time.Hour)
	}
	fmt.Println("done")
}
len, cap: 0 0
len after expiry: 0
Stop: true
no stale value after Stop
fired after Reset: true
Stop after receive: false
done

How to read this:

  • len(t.C) and cap(t.C) are both 0 (they were 0 and 1 before the change), and an expired timer that nobody received from does not "contain" a value. A timer’s value is produced at the moment of the receive.

  • Stop returns true here even though the 10 ms deadline had long passed, because the value had never been received: from the program’s point of view the timer had not yet delivered. Stop returns false only when the value was already received (the second-to-last line) or the timer was already stopped. After Stop returns, a receive from t.C blocks rather than yielding a stale value.

  • Reset after expiry is safe without draining: any receive after Reset returns sees the new deadline’s time.

  • The draining idiom still compiles, but it is dead weight: if !t.Stop() { <-t.C } can now block forever when Stop returns false because the value was already received. Delete it. The same holds for the defer t.Stop() added only to help the collector; keep it when the stop has a real purpose such as releasing an AfterFunc.

  • The behaviour was gated by the go line in go.mod: modules declaring go 1.23 or newer got the new channels in Go 1.23 to 1.26, and older modules could opt in or out with the asynctimerchan GODEBUG setting.

asynctimerchan is gone in Go 1.27

asynctimerchan was the switch: asynctimerchan=1 restored the asynchronous channels. The Go 1.23 documentation said it would be removed in Go 1.27, and it was. The Go 1.27 release notes say that channels created by the time package are now always unbuffered, irrespective of GODEBUG settings, and the toolchain’s godebug.md lists the setting among the removed ones. A module that still carries the old setting does not build with a clear message:

module example.com/old

go 1.22

godebug asynctimerchan=1
go: error loading go.mod:
go.mod:5: removed GODEBUG "asynctimerchan" set to old value "1" (https://go.dev/doc/godebug#go-127)

The same message is reported for a //go:debug asynctimerchan=1 line in a .go file. Two consequences follow.

  • A module declaring go 1.22 (or godebug default=go1.22) and built with Go 1.27 also gets synchronous channels: the program printing len(t.C), cap(t.C) printed 0 0 with both go.mod forms when run with this toolchain. The language-version gate no longer applies to timers.

  • Go 1.27 also changes how the go command treats removed settings in general: it accepts a removed GODEBUG setting in go.mod or //go:debug only when it is set to the final default, the value the runtime now uses anyway. Delete every asynctimerchan line; it never has an effect in 1.27.

The lifecycle of a channel-based timer under the 1.23 rules:

stateDiagram-v2 [*] --> Pending: NewTimer(d) Pending --> Fired: d elapsed Fired --> Received: value received from t.C Pending --> Stopped: Stop() returns true Fired --> Stopped: Stop() returns true (value not yet received) Received --> Pending: Reset(d) Stopped --> Pending: Reset(d) Fired --> Pending: Reset(d) (old value discarded) Received --> [*] Stopped --> [*]

Patterns: one timer for many waits, and an idle timeout

A loop that waits for events or a timeout should reuse one Timer with Reset rather than calling time.After per iteration; each time.After call allocates a new timer, which the collector now reclaims but which still costs an allocation on a hot path.

package main

import (
	"fmt"
	"time"
)

// drain forwards events until none arrives for idle, resetting one Timer
// instead of allocating a time.After per iteration.
func drain(events <-chan string, idle time.Duration) {
	timer := time.NewTimer(idle)
	defer timer.Stop()
	for {
		select {
		case e, ok := <-events:
			if !ok {
				fmt.Println("channel closed")
				return
			}
			fmt.Println("event:", e)
			// Since Go 1.23 no <-timer.C drain is required before Reset.
			timer.Reset(idle)
		case <-timer.C:
			fmt.Println("idle for", idle)
			return
		}
	}
}

func main() {
	events := make(chan string)
	go func() {
		for _, e := range []string{"lend", "return", "renew"} {
			events <- e
			time.Sleep(5 * time.Millisecond)
		}
	}()
	drain(events, 50*time.Millisecond)

	defer func() { fmt.Println("recovered:", recover()) }()
	_ = time.NewTicker(0)
}
event: lend
event: return
event: renew
idle for 50ms
recovered: non-positive interval for NewTicker

The program ends by showing the panic documented for NewTicker: a non-positive interval is a programming error and panics with non-positive interval for NewTicker, whereas time.Tick silently returns a nil channel (which blocks forever in a select). Validate intervals read from configuration before they reach either.

Other rules for waiting:

  • Never use time.Sleep to wait for another goroutine in a test or in production; wait on a channel, a WaitGroup or a context.

  • Backoff with jitter for retries is time.Duration arithmetic plus math/rand/v2 (rand.N(maxJitter)) and a select between ctx.Done() and a timer; Concurrency Patterns shows the loop.

  • A wait is "at least" d, never "exactly" d: the timer resolution depends on the operating system and the scheduler.

  • HTTP and database timeouts belong in http.Server, http.Client and request contexts, not in goroutine-and-timer constructions; see net/http Clients and Servers.

Measuring elapsed time

Start with start := time.Now() and end with time.Since(start): both use the monotonic reading, so the result is a correct duration even if the wall clock changed in between. time.Until(t) is t.Sub(time.Now()). When the measurement is deferred, the arguments of a deferred call are evaluated when the defer statement executes, which is a classic slip:

package main

import (
	"fmt"
	"time"
)

func report(label string, d time.Duration) {
	fmt.Println(label, d < time.Millisecond)
}

func work() {
	start := time.Now()
	// Wrong: time.Since(start) is evaluated now, when defer runs the statement.
	defer report("evaluated early, under 1ms:", time.Since(start))
	// Right: the closure reads the clock when the function returns.
	defer func() { report("evaluated late, under 1ms:", time.Since(start)) }()
	time.Sleep(30 * time.Millisecond)
}

func main() { work() }

go vet reports it with its defers analyzer:

./main.go:15:46: call to time.Since is not deferred
evaluated late, under 1ms: false
evaluated early, under 1ms: true

The first line printed is the correct one (the closure runs when the function returns, about 30 ms later, so the comparison d < 1ms is false), and the second is the early one, evaluated at the moment of the defer and therefore nearly zero. The deferred function value (a closure) is the fix. For benchmarks do not hand-roll this at all: use testing.B and b.Loop, see Benchmarks, Fuzzing and synctest.

Source: cmd/vet

Testing code that depends on time

Tests that sleep for real are slow, and tests that sleep for a guess are flaky. There are three tools, from simplest to most powerful.

  1. Pass the time in. A function that needs "now" takes a time.Time argument, or the type holds a func() time.Time field defaulting to time.Now. Loan.IsOverdue(now time.Time) in the next listing needs no fake clock at all, and a test passes due and due.Add(time.Nanosecond). This is the right design for pure decisions.

  2. Inject the waiting when production code must block (an interface with After(d) <-chan time.Time, or a function field). It works on every Go version, at the price of an abstraction that production code never changes.

  3. Use testing/synctest when the production code uses the real time package. Inside a bubble, time.Now, Sleep, timers and tickers run on a fake clock that starts at midnight UTC on 2000-01-01 and jumps forward only when every goroutine in the bubble is durably blocked, so a three-hour wait takes microseconds and is deterministic. synctest.Test was experimental in Go 1.24 (GOEXPERIMENT=synctest), generally available in Go 1.25, and gained synctest.Sleep (sleep and wait until all goroutines settle) in Go 1.27. The rules of a bubble, failure modes, the fake network and the details are on Benchmarks, Fuzzing and synctest; this page shows one more example, with a ticker.

The code under test sends a reminder every interval once a loan is overdue. overdue.go:

package overdue

import (
	"context"
	"time"
)

// Loan is a book lent until Due.
type Loan struct {
	Member string
	Due    time.Time
}

// IsOverdue reports whether l is past due at now. The caller supplies the
// clock reading, so tests need no fake clock at all.
func (l Loan) IsOverdue(now time.Time) bool { return now.After(l.Due) }

// Remind sends the member's name on out every interval once l is overdue,
// until ctx is done.
func Remind(ctx context.Context, l Loan, interval time.Duration, out chan<- string) {
	tk := time.NewTicker(interval)
	defer tk.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case now := <-tk.C:
			if l.IsOverdue(now) {
				out <- l.Member
			}
		}
	}
}

and overdue_test.go, which is a _test.go file because testing/synctest is only meant for tests and runs with go test:

package overdue

import (
	"context"
	"testing"
	"testing/synctest"
	"time"
)

func TestIsOverdue(t *testing.T) {
	due := time.Date(2026, time.October, 18, 0, 0, 0, 0, time.UTC)
	l := Loan{Member: "ada", Due: due}
	if l.IsOverdue(due) {
		t.Error("overdue exactly at the due time")
	}
	if !l.IsOverdue(due.Add(time.Nanosecond)) {
		t.Error("not overdue one nanosecond later")
	}
}

func TestRemind(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		start := time.Now() // always midnight UTC 2000-01-01 in a bubble
		t.Logf("bubble starts at %s", start.UTC().Format(time.DateTime))

		l := Loan{Member: "ada", Due: start.Add(2 * time.Hour)}
		out := make(chan string)
		ctx, cancel := context.WithCancel(t.Context())
		go Remind(ctx, l, time.Hour, out)

		// Nothing is sent before the loan is due: let the goroutine reach
		// its select and the clock reach the first tick, one hour in.
		synctest.Sleep(time.Hour)
		select {
		case m := <-out:
			t.Fatalf("early reminder for %s at %s", m, time.Since(start))
		default:
		}

		// The tick at 2h is not yet past the due time; the one at 3h is.
		synctest.Sleep(time.Hour)
		<-out
		t.Logf("first reminder after %s", time.Since(start))

		cancel()
		synctest.Wait() // Remind has returned; no goroutine leaks from the bubble
	})
}
=== RUN   TestIsOverdue
--- PASS: TestIsOverdue (0.00s)
=== RUN   TestRemind
    overdue_test.go:24: bubble starts at 2000-01-01 00:00:00
    overdue_test.go:43: first reminder after 3h0m0s
--- PASS: TestRemind (0.00s)
PASS
ok  	example.com/shelf/e09	0.403s

The test would take three real hours. Points worth noting:

  • time.Now() in the bubble is 2000-01-01 00:00:00 UTC regardless of the machine; print it with .UTC(), because Now returns the Local zone (the first attempt at this example printed 01:00 on a machine in Central European time).

  • synctest.Sleep(time.Hour) advances the clock one hour and returns when the other goroutines have reacted. The ticker fires at 1 h (not overdue: no message), at 2 h (equal to the due time, After is false: no message) and at 3 h (message). The assertion that no message arrived uses select with default, which is safe because the goroutines are quiescent.

  • <-out blocks durably, so the bubble’s clock jumps to the next tick instead of waiting.

  • The goroutine is stopped by cancelling a context derived from t.Context(), and synctest.Wait() confirms it returned. A goroutine that is still blocked when the test function returns makes Test panic or fail, which is a feature: it finds leaks.

  • Operations that wait on the outside world (network reads, mutex locks, system calls) are not durably blocking, so a bubble containing them cannot fast-forward; test the time logic separately from the I/O.

Pitfalls

Pitfall Symptom Fix

n * time.Second with an int n

compile error: mismatched types

time.Duration(n) * time.Second

1.5 * time.Second

compile error: truncated to int64

1500 * time.Millisecond

t1 == t2 for instants

false for the same instant in two zones or after Round(0)

t1.Equal(t2), or Compare

time.Time as a map key

duplicate keys for the same instant

key by UnixNano() or a normalised UTC().Round(0)

Layout built from today’s date (2016-01-02, YYYY-MM-DD)

garbled output

use the reference time or a constant such as time.DateOnly

time.Parse of a local time without a zone

the value is read as UTC

time.ParseInLocation

Add(24 * time.Hour) for "tomorrow"

off by an hour near daylight saving changes

AddDate(0, 0, 1)

Truncate(24 * time.Hour) for "start of day"

midnight UTC, not local

time.Date(y, m, d, 0, 0, 0, 0, loc)

defer log(time.Since(start))

duration near zero; go vet reports it

defer func() { log(time.Since(start)) }()

LoadLocation in a minimal container

unknown time zone

import time/tzdata in main

time.Sleep to wait for a goroutine

flaky or slow

channel, WaitGroup, context

<-t.C after Stop

blocks forever

select with a done channel; do not drain

Using time.Now() deep inside business logic

tests need real waiting

pass the time in, or use synctest

Round(0) forgotten before storing or hashing a Now() value

the monotonic reading makes == and String() differ from a reloaded copy

t.Round(0), or store UnixNano

Where the books are outdated

The two books this reference consulted for structure predate the changes below; the corrected form is what this page teaches.

  • Timer channels and Stop/Reset. Learning Go Programming (Vivien, ch. 9) describes time.After as a channel that gets closed; the channel receives one value and is never closed. Anything written before Go 1.23 about draining t.C before Reset, calling defer t.Stop() to avoid a leak, or time.Tick leaking its ticker, is obsolete: since Go 1.23 timers are collected when unreferenced and the channel is synchronous, and since Go 1.27 the old behaviour cannot be restored with asynctimerchan.

  • Layout constants. Learning Go (Bodner, ch. 11) shows layouts spelled out by hand; Go 1.20 added DateTime, DateOnly and TimeOnly, and Time.Compare.

  • Time and testing. Both books test time-dependent code with real sleeps or hand-written fakes; testing/synctest is generally available since 1.25, with synctest.Sleep in 1.27.

  • Duration.Abs (1.19), Time.ZoneBounds (1.19), Time.IsDST (1.17) and time.UnixMilli (1.17) are newer than both books' time sections.

  • Modern companions. Where older tutorials call rand.Seed or ioutil helpers next to time, use math/rand/v2 (rand.N(d) accepts a Duration) and os/io; see What’s New and Migrating Older Code.

Version notes

  • Go 1.9: monotonic clock readings in Time (Now carries one); Duration.Round and Truncate.

  • Go 1.15: Ticker.Reset.

  • Go 1.17: Time.IsDST, UnixMilli, Time.UnixMilli.

  • Go 1.19: Duration.Abs, Time.ZoneBounds.

  • Go 1.20: time.DateTime, DateOnly, TimeOnly; Time.Compare.

  • Go 1.23: timers and tickers are garbage collected when unreferenced; the timer channel is synchronous; asynctimerchan added as the opt-out.

  • Go 1.24: T.Context; testing/synctest experimental (GOEXPERIMENT=synctest).

  • Go 1.25: testing/synctest generally available with synctest.Test.

  • Go 1.27: asynctimerchan removed; synctest.Sleep; httptest.NewTestServer on an in-memory network for use in a bubble.