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 |
|
Loan periods, timeouts, intervals, measuring elapsed time |
An instant |
|
Due dates, timestamps, ordering events |
Calendar arithmetic and fields |
|
"Due in three weeks", "start of the day", "which week is it" |
Time zones |
|
Showing a time to a person in their zone, branch opening hours |
Text |
|
Logs, APIs, files, user input |
Waiting |
|
Timeouts, retries with backoff, periodic jobs, expiring holds |
Test support |
|
Fast, deterministic tests of timeouts and tickers |
Source: time package documentation
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 |
|---|---|
|
1 |
|
1000 nanoseconds |
|
1000 microseconds |
|
1000 milliseconds |
|
60 seconds |
|
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,MinutesandSecondsreturnfloat64;Milliseconds,MicrosecondsandNanosecondsreturnint64. Use the integer ones for exact values. -
RoundandTruncateround a duration to a multiple ofm; 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. -
ParseDurationaccepts no days (3 days, or1d): the error names the unit it could not read. -
A
Durationconverts to a number (int64(d)) and back, and prints with%das raw nanoseconds, with%vand%sthroughString. -
Arithmetic on
Durationis plainint64arithmetic and wraps around on overflow, as the last line shows.Time.Subis 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
Source: time: Monotonic Clocks
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 == t2compares the struct, including the location pointer and the monotonic reading, so the same instant in UTC and inFixedZone("CET", 3600)is not==. The output above printsa == b: falseanda.Equal(b): truefor the same instant. It is wrong in almost every program, andreflect.DeepEqualhas the same problem because it compares the fields. -
t1.Compare(t2)(Go 1.20) returns-1,0or+1and is meant for sorting:slices.SortFunc(loans, func(a, b Loan) int { return a.Due.Compare(b.Due) }).BeforeandAfterare 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 theTZenvironment variable or the system’s configuration. Servers usually run in UTC, laptops do not; code that works inLocaltherefore 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" useAddDate(0, 0, 21); for "the hold expires in 48 hours" useAdd(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
Datewithout 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 zeroTimewhen unbounded), andIsDST()(Go 1.17) whether daylight saving is in effect. Abbreviations are not unique (ISTis Indian, Irish and Israel Standard Time) and must not be parsed back for a zone. -
Datenormalises out-of-range fields instead of rejecting them: month 13 becomes January of the next year,January 31 + 1 monthbecomes March 3 (February 31 does not exist), andFebruary 30, 25:61carries over into March. Validate user-supplied dates by comparing the fields you passed with the fields of the result.
Source: time.LoadLocation
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
Source: time.Time.Format
The elements, shown by formatting 2026-03-05 07:08:09.12 in America/New_York:
| Field | Layout element | Output | Notes |
|---|---|---|---|
Year |
|
|
Four digits or two. |
Month |
|
|
Name, abbreviation, zero-padded number, number. |
Weekday |
|
|
|
Day of month |
|
|
|
Day of year |
|
|
Zero- or space-padded. |
Hour |
|
|
|
Minute |
|
|
|
Second |
|
|
|
AM or PM |
|
|
Needs a 12-hour element to be meaningful. |
Fraction |
|
|
Zeros keep trailing zeros, nines trim them; a fixed number of digits; a comma is accepted as the separator. |
Zone abbreviation |
|
|
|
Zone offset |
|
|
|
Zone offset, |
|
|
Prints |
The package defines the common layouts as constants; prefer them to hand-written strings.
| Constant | Layout |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Parsing, locations and errors
-
time.Parse(layout, s)returns a time in UTC whenshas 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’sLocaldoes not know. Prefer numeric offsets in anything you control. -
time.ParseInLocation(layout, s, loc)is the correct call for a string such as2026-11-08 09:30:00that means "9:30 in this branch’s zone"; the example shows the result inAmerica/New_York(-0500 EST, because daylight saving has ended by that date). -
Failures are
*time.ParseErrorvalues with the layout, the value and the element that failed. Beyond a wrong layout they include a day that does not exist (2026-02-30isday out of range); unlikeDate,Parsevalidates. -
Leap seconds and
:60are 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
Source: time.Time.MarshalJSON
-
After a JSON round trip the instant is
Equalto the original and, because the decoded value has a different*Location, it is not==. Compare withEqual. -
{"due":"2026-10-18"}fails because only RFC 3339 is accepted. For dates-only fields define a type with its ownUnmarshalJSONusingtime.DateOnly, or decode into astring. The page encoding/json shows custom marshalers and theencoding/json/v2formatting options (v2 can write durations as strings or as nanoseconds). -
A
Timeas a map key distinguishes the zones and the monotonic reading: the same instant inESTis a different key. Uset.UnixNano()(ort.UTC().Round(0)when you need aTime) as the key. -
Truncation to a day boundary must use
time.Datewith 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 |
|---|---|---|
|
once, after |
the time on |
|
once, after |
|
|
once, after |
runs |
|
every |
the time on |
|
every |
|
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
selectusestime.Afteras a one-shot timeout. Thefetchhelper’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). -
Tickersends on its channel at a fixed rate and drops ticks for a receiver that is slower than the period instead of queuing them. AlwaysStopa 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. -
AfterFuncrunsfon its own goroutine.Stopreturnsfalseoncefhas started (not once it has finished); it does not wait forf. If the caller needs to know thatfis complete it must coordinate, for example with a channel as the example’sdone. A stoppedAfterFuncnever runs. -
For a deadline that crosses function boundaries,
context.WithTimeoutis better than a hand-passed timer: it composes with parent cancellation and shows up inctx.Err(). The lastselectuses 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.
-
Timers were not garbage collected until they fired. A
time.After(time.Hour)in a loop leaked an hour-long timer per iteration, and everytime.NewTimerwas followed by adefer t.Stop()to avoid it, whiletime.Tickwas documented as leaking its ticker forever. -
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
StoporResetcould not remove it. The code that followed the rules read like this:if !t.Stop() { <-t.C }before everyReset, 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)andcap(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. -
Stopreturnstruehere 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.Stopreturnsfalseonly when the value was already received (the second-to-last line) or the timer was already stopped. AfterStopreturns, a receive fromt.Cblocks rather than yielding a stale value. -
Resetafter expiry is safe without draining: any receive afterResetreturns 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 whenStopreturns false because the value was already received. Delete it. The same holds for thedefer t.Stop()added only to help the collector; keep it when the stop has a real purpose such as releasing anAfterFunc. -
The behaviour was gated by the
goline ingo.mod: modules declaringgo 1.23or newer got the new channels in Go 1.23 to 1.26, and older modules could opt in or out with theasynctimerchanGODEBUG 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(orgodebug default=go1.22) and built with Go 1.27 also gets synchronous channels: the program printinglen(t.C), cap(t.C)printed0 0with 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
gocommand treats removed settings in general: it accepts a removed GODEBUG setting ingo.modor//go:debugonly when it is set to the final default, the value the runtime now uses anyway. Delete everyasynctimerchanline; it never has an effect in 1.27.
Source: Go 1.27 release notes
The lifecycle of a channel-based timer under the 1.23 rules:
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
Source: time.Timer.Reset
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.Sleepto wait for another goroutine in a test or in production; wait on a channel, aWaitGroupor acontext. -
Backoff with jitter for retries is
time.Durationarithmetic plusmath/rand/v2(rand.N(maxJitter)) and aselectbetweenctx.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.Clientand 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.
-
Pass the time in. A function that needs "now" takes a
time.Timeargument, or the type holds afunc() time.Timefield defaulting totime.Now.Loan.IsOverdue(now time.Time)in the next listing needs no fake clock at all, and a test passesdueanddue.Add(time.Nanosecond). This is the right design for pure decisions. -
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. -
Use
testing/synctestwhen the production code uses the realtimepackage. 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.Testwas experimental in Go 1.24 (GOEXPERIMENT=synctest), generally available in Go 1.25, and gainedsynctest.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
}
}
}
}
Source: time.NewTicker and time.Time.After
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
Source: testing/synctest
The test would take three real hours. Points worth noting:
-
time.Now()in the bubble is2000-01-01 00:00:00 UTCregardless of the machine; print it with.UTC(), becauseNowreturns theLocalzone (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,Afteris false: no message) and at 3 h (message). The assertion that no message arrived usesselectwithdefault, which is safe because the goroutines are quiescent. -
<-outblocks 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(), andsynctest.Wait()confirms it returned. A goroutine that is still blocked when the test function returns makesTestpanic 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 |
|---|---|---|
|
compile error: mismatched types |
|
|
compile error: truncated to int64 |
|
|
false for the same instant in two zones or after |
|
|
duplicate keys for the same instant |
key by |
Layout built from today’s date ( |
garbled output |
use the reference time or a constant such as |
|
the value is read as UTC |
|
|
off by an hour near daylight saving changes |
|
|
midnight UTC, not local |
|
|
duration near zero; |
|
|
|
import |
|
flaky or slow |
channel, |
|
blocks forever |
select with a done channel; do not drain |
Using |
tests need real waiting |
pass the time in, or use |
|
the monotonic reading makes |
|
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) describestime.Afteras a channel that gets closed; the channel receives one value and is never closed. Anything written before Go 1.23 about drainingt.CbeforeReset, callingdefer t.Stop()to avoid a leak, ortime.Tickleaking 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 withasynctimerchan. -
Layout constants. Learning Go (Bodner, ch. 11) shows layouts spelled out by hand; Go 1.20 added
DateTime,DateOnlyandTimeOnly, andTime.Compare. -
Time and testing. Both books test time-dependent code with real sleeps or hand-written fakes;
testing/synctestis generally available since 1.25, withsynctest.Sleepin 1.27. -
Duration.Abs(1.19),Time.ZoneBounds(1.19),Time.IsDST(1.17) andtime.UnixMilli(1.17) are newer than both books' time sections. -
Modern companions. Where older tutorials call
rand.Seedorioutilhelpers next totime, usemath/rand/v2(rand.N(d)accepts aDuration) andos/io; see What’s New and Migrating Older Code.
Version notes
-
Go 1.9: monotonic clock readings in
Time(Nowcarries one);Duration.RoundandTruncate. -
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;
asynctimerchanadded as the opt-out. -
Go 1.24:
T.Context;testing/synctestexperimental (GOEXPERIMENT=synctest). -
Go 1.25:
testing/synctestgenerally available withsynctest.Test. -
Go 1.27:
asynctimerchanremoved;synctest.Sleep;httptest.NewTestServeron an in-memory network for use in a bubble.