The time Package in Go: time.Time, Duration, Formatting with the Reference Time ("2006-01-02"), Time Zones and the Monotonic Clock
In Go a date format is not given with codes such as yyyy-MM-dd but by showing how the reference time 2006-01-02 15:04:05 is written; "yyyy-MM-dd" does not fail, it is printed as it is. Duration, json/v2 giving Duration no default format, time zones and the three-hour shift from post 11, time/tzdata on scratch images and Windows, a delivery estimate on the warehouse clock, the monotonic clock and Equal, and the time.After trap.
Version note: The code in this post was built and run with Go 1.27.4; the Bookstore module's go line is 1.26.0, and the code also compiles and passes its tests with Go 1.26.11. encoding/json/v2 arrived in Go 1.27, so the lab that uses it is set apart with //go:build go1.27. time.DateOnly and time.DateTime arrived in Go 1.20, testing/synctest in Go 1.25. The container experiment used the scratch and gcr.io/distroless/static-debian13:nonroot images for linux/amd64.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, a C# developer writes the order date to the log and, out of habit, types this line: t.Format("yyyy-MM-dd HH:mm"). The compiler says nothing and no test fails. What do you see in the log?
// swapped is a variable, not a constant, so go vet does not see it.
var swapped = "2006-02-01 03:04"
func main() {
ist, err := time.LoadLocation("Europe/Istanbul")
if err != nil {
panic(err)
}
t := time.Date(2026, 11, 14, 21, 5, 7, 123456789, ist)
fmt.Println(t.Format("yyyy-MM-dd HH:mm")) // the C# habit: no error, no date
fmt.Println(t.Format(swapped)) // day and month swapped, 12-hour clock
fmt.Println(t.Format("2006-01-02 15:04"))
fmt.Println(t.Format("02.01.2006 Monday"))
fmt.Println(t.Format(time.RFC3339), "|", t.Format(time.RFC3339Nano))
fmt.Println(t.Format(time.DateOnly), "|", t.Format(time.Kitchen))
for _, s := range []string{"2026-11-14", "14.11.2026", "2026-11-31"} {
d, err := time.Parse(time.DateOnly, s)
fmt.Println(d, err)
}
}
$ go run ./labs/16-layout
yyyy-MM-dd HH:mm
2026-14-11 09:05
2026-11-14 21:05
14.11.2026 Saturday
2026-11-14T21:05:07+03:00 | 2026-11-14T21:05:07.123456789+03:00
2026-11-14 | 9:05PM
2026-11-14 00:00:00 +0000 UTC <nil>
0001-01-01 00:00:00 +0000 UTC parsing time "14.11.2026" as "2006-01-02": cannot parse "14.11.2026" as "2006"
0001-01-01 00:00:00 +0000 UTC parsing time "2026-11-31": day out of range
The first line is not a date; it is the layout itself. In Go a format is not described with letter codes such as yyyy, MM and dd, but by showing how a fixed reference time should be written, and since none of those letters is part of that reference, they were printed as they are. The second line is more dangerous: day and month swapped places and 21:05 became "09:05". This post covers the time package: formatting and parsing with the reference time, Duration, time zones and time zone data in containers, the monotonic clock and Equal, and timers. We also add a delivery estimate to Bookstore that works on the warehouse clock.
The Reference Time: 2006-01-02 15:04:05
The reference time is Mon Jan 2 15:04:05 MST 2006. In American date order it reads 1, 2, 3, 4, 5, 6, 7: month 01, day 02, hour 03 (15 on the 24-hour clock), minute 04, second 05, year 06, zone -0700. Whichever part you write into the layout, and however you write it, is how that part comes out: "02.01.2006" is the Turkish date order, "Monday" the weekday name, "Jan" the short month name. Day and month names are always English; Go's standard library has no Turkish month names, so if you need them you write a small table.
Most of the time you do not need to write a layout, because there are constants: time.RFC3339 for APIs and logs (or RFC3339Nano with fractional seconds), time.DateOnly for a date, time.DateTime for date and time. RFC3339 is the format defined in RFC 3339, a plain profile of ISO 8601 used on the internet; time.Time is written in this format in JSON too. Parsing uses the same layout in the other direction, and its error says exactly what did not match; it also rejects days that do not exist, such as 31 November. The timeformat check in go vet catches the most common mistake; that is why the lab keeps the layout in a variable. Written as a literal, vet says:
$ go vet ./labs/16-layout
labs/16-layout/main.go:17:24: 2006-02-01 should be 2006-01-02
So the counterpart of DateTime.ToString("yyyy-MM-dd") from the "Dates and Times in C#" post of the C# Fundamentals series is t.Format(time.DateOnly).
Duration and Time Arithmetic
time.Duration is an int64 of nanoseconds, the counterpart of C#'s TimeSpan. t.Add(d), t.Sub(u), time.Since(t) and time.Until(t) cover most daily work; the BOOKSTORE_DRAIN_DELAY setting from post 13 was read with time.ParseDuration. Go's type rules will stop you here once:
func main() {
attempt := 3
fmt.Println(attempt * time.Second) // int times Duration
}
$ go build .
# dur
./main.go:10:14: invalid operation: attempt * time.Second (mismatched types int and time.Duration)
3 * time.Second compiles because 3 is an untyped constant; attempt is a variable of type int, and Go does no implicit conversion. The right form is time.Duration(attempt) * time.Second. Take care when you write a Duration to JSON too: v1 writes the nanoseconds as a number, while json/v2 in Go 1.27 does not pick a default format:
func main() {
p := retryPolicy{Delay: 1500 * time.Millisecond}
b, err := jsonv1.Marshal(p)
fmt.Println("v1: ", string(b), err)
b, err = json.Marshal(p)
fmt.Println("v2: ", string(b), err)
b, err = json.Marshal(p, jsonv1.FormatDurationAsNano(true))
fmt.Println("v2 nano: ", string(b), err)
b, err = json.Marshal(retryPolicyFormat(p))
fmt.Println("v2 tag: ", string(b), err)
fmt.Println("String():", p.Delay) // what ParseDuration reads back
}
$ go run ./labs/16-durationjson
v1: {"delay":1500000000} <nil>
v2: {"delay" json: cannot marshal from Go time.Duration within "/delay": no default representation
v2 nano: {"delay":1500000000} <nil>
v2 tag: json: cannot marshal from Go main.retryPolicyFormat: Go struct field Delay has unsupported `format` tag option
String(): 1.5s
During the experiment, json/v2 accepted struct tag options such as format:units; according to a note in the Go 1.27.4 source code, that support was taken out of the first release because typed struct tags may come to the language later (Go issue #79071). Today there are two ways: the FormatDurationAsNano(true) option from the v1 package, or keeping the value as a string and reading it with ParseDuration. Note that the failing Marshal also returns half an output ({"delay"); do not use the output when there is an error.
Time Zones: One Instant, Many Wall Clocks
A time.Time carries an instant together with the time zone to show it in. In changes the presentation without changing the instant, and UTC is its shortcut:
func main() {
ist := must(time.LoadLocation("Europe/Istanbul"))
berlin := must(time.LoadLocation("Europe/Berlin"))
placed := time.Date(2026, 11, 14, 21, 30, 0, 0, ist)
fmt.Println(placed, "|", placed.UTC(), "|", placed.In(berlin))
fmt.Println(placed.Equal(placed.UTC()), placed == placed.UTC())
// Parse without an offset means UTC; ParseInLocation means local wall clock.
a, _ := time.Parse(time.DateTime, "2026-11-14 21:30:00")
b, _ := time.ParseInLocation(time.DateTime, "2026-11-14 21:30:00", ist)
fmt.Println(a, "|", b, "|", a.Sub(b))
// Post 11: datetime2 keeps the wall clock and drops the offset.
stored := time.Date(placed.Year(), placed.Month(), placed.Day(), placed.Hour(), placed.Minute(), 0, 0, time.UTC)
fmt.Println("read back:", stored, "shift:", stored.Sub(placed))
// Truncate works on absolute time, so a "day" starts at 00:00 UTC.
fmt.Println(placed.Truncate(24 * time.Hour))
// Berlin leaves daylight saving time on 2026-10-25.
noon := time.Date(2026, 10, 24, 12, 0, 0, 0, berlin)
fmt.Println(noon.Add(24*time.Hour), "|", noon.AddDate(0, 0, 1))
}
$ go run ./labs/16-zones
2026-11-14 21:30:00 +0300 +03 | 2026-11-14 18:30:00 +0000 UTC | 2026-11-14 19:30:00 +0100 CET
true false
2026-11-14 21:30:00 +0000 UTC | 2026-11-14 21:30:00 +0300 +03 | 3h0m0s
read back: 2026-11-14 21:30:00 +0000 UTC shift: 3h0m0s
2026-11-14 03:00:00 +0300 +03
2026-10-25 11:00:00 +0100 CET | 2026-10-25 12:00:00 +0100 CET
The three presentations on the first line, Istanbul, UTC and Berlin, show the same instant; Equal knows that, == does not. time.Parse treats text without an offset as UTC, ParseInLocation reads it as the wall clock of the given zone; the same "21:30" became two instants three hours apart. The bug in post 11 is exactly the case on the fourth line: the driver sent the instant with its offset, the datetime2 column kept the wall clock and dropped the offset, and on the way back it was taken for UTC. The rule is simple: write UTC to the database and the API, and convert to local time only for display.
The last two lines are two small traps. Truncate(24 * time.Hour) does not give "the start of the day", because it works on absolute time, and in Istanbul it gave 03:00; for local midnight use time.Date(y, m, d, 0, 0, 0, 0, loc). Türkiye has not used daylight saving time since 2016, but Berlin turned its clocks back on 25 October and Add(24*time.Hour) landed on 11:00; to add a calendar day use AddDate(0, 0, 1).
Time Zone Data in Containers and on Windows
LoadLocation looks for the IANA time zone database, in order, in the ZONEINFO environment variable, in system folders such as /usr/share/zoneinfo on Unix, in time/tzdata embedded in the binary, and in $GOROOT/lib/time/zoneinfo.zip. For Windows the source code has no system source; on a Windows server without Go installed, the data exists only if time/tzdata was imported. A scratch image has none of them either:
$ docker run --rm goseries-tz:plain
error: unknown time zone Europe/Istanbul
$ docker run --rm goseries-tz:embed # -tags timetzdata
2026-11-14 21:30:00 +0300 +03
$ docker run --rm -e TZ=Europe/Istanbul goseries-tz:plain
error: unknown time zone Europe/Istanbul
The distroless image from post 13 contains time zone data, and the same binary worked there. Windows' own time zone ids ("Turkey Standard Time") and how they differ from IANA names were covered in the date and time post of the Everyday .NET APIs with C# series; Go expects the IANA name on every platform. Still, I added one line to Bookstore's main and made it independent of where it is deployed:
_ "time/tzdata" // about 450 KB: time zones work on scratch images and Windows hosts
The cost on the Linux binary is 13,167,005 versus 13,578,629 bytes, about 400 KB. The warehouse time zone now comes from the BOOKSTORE_WAREHOUSE_TZ setting (default Europe/Istanbul), and config.FromEnv from post 13 catches a wrong name at start-up:
$ BOOKSTORE_WAREHOUSE_TZ=Europe/Istanbol ./bin/bookstore-api
bookstore-api: BOOKSTORE_WAREHOUSE_TZ: unknown time zone Europe/Istanbol
Bookstore: Delivery Estimate
The rule: an order placed before 15:00 on the warehouse clock ships the same business day, later orders the next business day; delivery is two business days after shipping. The calculation has to happen on the warehouse clock, because the server runs in UTC and the order time is stored in UTC:
// Estimate returns the delivery date as midnight in r.Loc.
func (r DeliveryRule) Estimate(placed time.Time) time.Time {
loc := r.Loc
if loc == nil {
loc = time.UTC
}
local := placed.In(loc) // the same instant on the warehouse clock
day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
if local.Hour() >= r.CutoffHour || !isBusinessDay(day) {
day = nextBusinessDay(day)
}
for range r.TransitDays {
day = nextBusinessDay(day)
}
return day
}
// nextBusinessDay adds calendar days with AddDate, not 24*time.Hour, so a
// daylight saving change cannot move the result off midnight.
func nextBusinessDay(day time.Time) time.Time {
day = day.AddDate(0, 0, 1)
for !isBusinessDay(day) {
day = day.AddDate(0, 0, 1)
}
return day
}
{"friday before cutoff", time.Date(2026, 11, 13, 14, 59, 0, 0, ist), "2026-11-17"},
{"friday at cutoff", time.Date(2026, 11, 13, 15, 0, 0, 0, ist), "2026-11-18"},
{"saturday", time.Date(2026, 11, 14, 10, 0, 0, 0, ist), "2026-11-18"},
{"utc noon is 15:00 in Istanbul", time.Date(2026, 11, 16, 12, 0, 0, 0, time.UTC), "2026-11-19"},
{"utc 21:30 is already tuesday", time.Date(2026, 11, 16, 21, 30, 0, 0, time.UTC), "2026-11-19"},
The fourth row does the real test: 12:00 in UTC is 15:00 in Istanbul and hits the cutoff; an implementation that calculates in UTC would return the 18th. The time comes in as a parameter, as in post 12; the test never looks at the real clock. The order response now carries the estimated date:
$ curl -s -X POST -H 'Content-Type: application/json' --data @internal/httpapi/testdata/place_order.json http://127.0.0.1:52715/api/orders
{"id":"ORD-1001","customerId":"ada","status":"Pending","items":[{"isbn":"9780441172719","title":"Dune","unitPrice":34990,"quantity":2}],"total":69980,"createdAt":"2026-11-15T03:09:50.835206Z","estimatedDelivery":"2026-11-18"}
The order was placed on Sunday morning: it ships on Monday and arrives on Wednesday. createdAt is UTC in RFC 3339, while estimatedDelivery is a day rather than a time, so it uses the DateOnly format; Go has no separate type like C#'s DateOnly.
The Monotonic Clock and Equal
The topic promised in post 12: time.Now() reads two clocks. One is the wall clock, which NTP or the user can move back and forth. The other is the monotonic clock, which only moves forward, and duration measurements use it:
func main() {
now := time.Now()
fmt.Println(now) // the tail shows the monotonic reading: m=+...
wall := now.Round(0) // Round(0) strips the monotonic reading
fmt.Println(now == wall, now.Equal(wall))
b, _ := json.Marshal(now)
var back time.Time
_ = json.Unmarshal(b, &back)
fmt.Println(string(b), now == back, now.Equal(back))
seen := map[time.Time]bool{now: true}
fmt.Println(seen[back], seen[now])
start := time.Now()
time.Sleep(20 * time.Millisecond)
fmt.Println(time.Since(start).Round(time.Millisecond)) // uses the monotonic clock
}
$ go run ./labs/16-monotonic
2026-11-15 06:07:16.205141 +0300 +03 m=+0.000080834
false true
"2026-11-15T06:07:16.205141+03:00" false true
false true
21ms
m=+0.000080834 is the monotonic reading. Round(0) strips it, and the same instant is no longer equal under ==. The value read back from JSON also loses the monotonic reading and the location pointer, which is why == and the map key give the wrong answer and Equal the right one. If you have to use a time.Time as a map key, bring it to one form first, such as t.UTC().Round(0), or use UnixNano(). Because time.Since uses the monotonic reading, it does not produce a negative duration even when the clock is adjusted.
Timer, Ticker and time.After
As we saw in post 14, since Go 1.23 timers and tickers that were never stopped are also collected by the GC; using time.After in a loop is no longer a memory leak. But it remains a logic bug:
// receiveAfter starts a new timer on every iteration.
func receiveAfter(ch <-chan int, timeout time.Duration) int {
n := 0
for {
select {
case _, ok := <-ch:
if !ok {
return n
}
n++
case <-time.After(timeout): // reset by every message
return n
}
}
}
$ go test -v -run TestTimeouts ./labs/16-timers
=== RUN TestTimeouts
timers_test.go:81: time.After got 10 updates in 4s
timers_test.go:81: deadline got 2 updates in 1s
--- PASS: TestTimeouts (0.00s)
The intent is "wait at most one second"; but because time.After creates a new timer on every iteration, every message reset the clock and the loop took 4 seconds. receiveDeadline creates a single time.NewTimer before the loop and stops it with defer Stop. Because the test runs inside synctest from post 12, the "4s" passed on the fake clock and the test finished in 0.00 seconds. It caught one more thing on the first try: when receiveDeadline gave up, the producer goroutine was blocked trying to send, and synctest failed the test with "deadlock: main bubble goroutine has exited but blocked goroutines remain"; giving the producer a context fixed it. For work at regular intervals, use time.NewTicker and Stop it when you are done.
Production Notes
UTC for storing and carrying, local time for display. The three-hour shift in post 11 was this rule forgotten in one layer. If you also want to store the offset in SQL Server, the column has to be datetimeoffset.
Put time zone data into the binary or the image, and check it at the first start. Catching a LoadLocation error during configuration is better than seeing a 500 on the first order. The IANA database is updated several times a year; time/tzdata ships with the Go release, so keeping Go up to date keeps this data up to date too.
Inject time. orders.Service.now and the parameter of DeliveryRule.Estimate keep tests independent of the real clock; for time-dependent goroutines there is synctest.
Common Pitfalls
Writing "yyyy-MM-dd". It does not fail, it prints the layout as it is; use time.DateOnly and run go vet in CI.
Comparing time.Time values with == or using them as map keys. The monotonic reading and a different location make the same instant look different; use Equal.
LoadLocation on a scratch image or a Windows server. Without time zone data you get "unknown time zone"; import time/tzdata.
Counting days with 24*time.Hour. In zones with daylight saving time a day lasts 23 or 25 hours; use AddDate and time.Date.
Building an overall timeout in a loop with time.After. It resets on every iteration; use one timer before the loop, or context.WithTimeout.
A Small Experiment
★☆☆ Add a function to labs/16-layout that writes "14 Kasım 2026 Cumartesi" with the Turkish month name: t.Month(), t.Weekday() and two small tables are enough.
★★☆ Add a list of public holidays to DeliveryRule (as []time.Time, or as a map[string]bool with "2006-01-02" keys). Add rows to the table test for the afternoon of 28 October and for 29 October; explain why you chose a string rather than time.Time as the map key.
★★★ Add a field that stores the delivery estimate to orders.Order and write it to a date column in sqlstore. Check with an integration test which location go-mssqldb uses when it reads a date column.
That is it for time calculations; in the next post we sort these dates and the books. With the slices, maps, sort and cmp packages we sort, search and group, and look at how a developer used to LINQ writes these things in Go.