15 Go Subtleties You May Not Already Know
32 points by fs111
32 points by fs111
If using the time.After example - ensure you are running Go 1.23 and have 1.23 in your mod file to opt-in to the new timer behaviour.
https://go.dev/wiki/Go123Timer
This example used to leak a goroutine in the happy-path case where the timeout did not fire - the old fix was something like the following (or deferring the Stop() if you had a function to exit out of), but 1.23 managed to close up this longstanding foot-gun by allowing the unfired timer to be GC'd
timeout := time.NewTimer(time.Second)
select {
case res := <-ch:
fmt.Println("Received:", res)
if !timeout.Stop() {
<-timeout.C
}
case <-timeout.C:
fmt.Println("Timeout: did not receive a result in time")
}
Great tips! If anyone is interested, I've added some commentary of my own below based on a ~decade of Go.
Constraining Generic Function Signatures
You can kind of do structural typing with this, in so much as the structure exactly matches. It's useful for packages that allow callers to provide their own types based off a core one.
Abbreviated example from https://github.com/orsinium-labs/enum:
// Package
type Member[T comparable] struct {
Value T
}
type Enum[M ~struct{ Value V }, V comparable] struct {
members []M
}
func New[M ~struct{ Value V }, V comparable](members ...M) Enum[M, V] {
return Enum[M, V]{members}
}
// Usage
type Color Member[string]
var (
Blue = Color{"blue"}
Red = Color{"red"}
Colors = New(Blue, Red)
)
Index-based String Interpolation
Another interpolation trick I like is using %T in type-switch default cases. Makes debugging easier, and it's easy to copy-paste around:
switch v := thing.(type) {
case Foo:
case Bar:
default:
return fmt.Errorf("unexpected type: %T", v)
}
Comparing Times
There are two things with time in Go that I constantly trip over:
IsZero()t.IsZero() returns true if t represents 0001-01-01T00:00:00Z. This means any system that deals with encoding/decoding UNIX timestamps, where 0 is the natural zero value, the naive check will fail.
ts1 := time.Unix(0, 0)
fmt.Println("1:", ts1.IsZero())
fmt.Println("1:", ts1.Unix() == 0)
// Unfortunately that constant is unexported, but it is the
// amount of seconds between 0001-01-01T00:00:00Z and
// 1970-01-01T00:00:00Z
ts2 := time.Unix(-62135596800, 0)
fmt.Println("2:", ts2.IsZero())
var ts3 time.Time
fmt.Println("3:", ts3.IsZero())
fmt.Println("3:", ts3.Unix())
// Output:
// 1: false
// 1: true
// 2: true
// 3: true
// 3: -62135596800
For Apple Silicon laptops, their monotonic clock only has microsecond-precision. This can make tests that rely on time equality to pass locally, but fail on other systems.
now := time.Now()
fmt.Printf("%d.%d\n", now.Unix(), now.Nanosecond())
fmt.Println("ns:", now.Nanosecond()/1e9)
now = time.Now()
fmt.Printf("%d.%d\n", now.Unix(), now.Nanosecond())
fmt.Println("ns:", now.Nanosecond()/1e9)
// Output:
// 1761143594.202720000
// ns: 0
// 1761143594.202744000
// ns: 0
For Apple Silicon laptops, their monotonic clock only has microsecond-precision. This can make tests that rely on time equality to pass locally, but fail on other systems.
Isn't this the use case for synctest (https://go.dev/blog/testing-time)?
Controlling when time can advance for sure helps, but there are still cases when you may not be able to use synctest. Like if your test involves I/O or syscalls.
You can generally fix the I/O part if you own your test stubs, such as using in-memory fakes (net.Pipe, github.com/akutz/memconn) or fs.Fs. I've run into a couple packages that use http/httptest internally without giving a way to override the listener. I try to submit PRs / issues in those cases, but sometimes you just need to be unblocked and get the test out. :)
t.IsZero() returns true if t represents 0001-01-01T00:00:00Z. This means any system that deals with encoding/decoding UNIX timestamps, where 0 is the natural zero value, the naive check will fail.
Yeah, ran into that one last week. Also if you have someone doing y := x * time.Second and then later someone does y * time.Second, hilarity ensues. Run into that one a few times when the first part is some files / methods / packages distant from the second.
Oof! durationcheck has been a lifesaver for that one for me.
Weirdly it doesn't seem to spot either of these (Extend is analogous to the bug we suffered.)
package main
import (
"fmt"
"time"
)
type Info struct {
Delay time.Duration
}
func main() {
i := &Info{
Delay: 42 * time.Second,
}
fmt.Printf("before: %v\n", i)
// bork
i.Extend(23)
fmt.Printf("extend: %v\n", i)
// bork
i.ExtendBy(23 * time.Second)
fmt.Printf("extendby: %v\n", i)
}
func (i *Info) Extend(s int) {
i.Delay *= time.Duration(s) * time.Second
}
func (i *Info) ExtendBy(t time.Duration) {
i.Delay *= t
}
Oh dang, looks like it doesn't work for *= assignment. Submitted a PR: https://github.com/charithe/durationcheck/pull/25
And yes, I'm incredibly susceptible to nerd sniping: https://xkcd.com/356/
// Unfortunately that constant is unexported, but it is the // amount of seconds between 0001-01-01T00:00:00Z and // 1970-01-01T00:00:00Z ts2 := time.Unix(-62135596800, 0) fmt.Println("2:", ts2.IsZero())
Yeah, it's not exported explicitly, but worth noting you can construct it:
ts := time.Time{}
fmt.Println(ts.IsZero())
fmt.Println(ts.Unix())
// Output:
// true
// -62135596800
wg.Go() will be very useful, that's a pattern I use all the time and kept wondering if I was doing it right. Turns out I'm not the only one who used it.
This one is my favorite: all json keys in go are case insensitive, and the case insensitivity is implemented using fancy Unicode case folding meaning there are some obscure glyphs that will be converted to ASCII chars, leading to parser differential type security issues.
See challenge 3 "shipping bay" on https://sigflag.at/blog/2025/writeup-uiuctf25-web/
But what if I want my JSON key to be "-"? I heavily dislike these sort of special values.
If you're in that 0.00001% of cases you can write your own JSON marshaler in about 4 lines of code. Probably better because a "-" key will require some documentation as to why the heck is it a dash.
Or you can use the also weird json:"-," tag and write nothing else. There's a reason this is weird: it's not a normal use case.
I assume this Go functionality is entirely compile-time, which means that the rest of this comment isn’t applicable at all (until, kinda, if you start generating code). But who knows.
In my opinion you can’t sweep these sorts of edge cases under the rug by saying hardly anyone needs them, because a lot of software is gonna have to deal with arbitrary data at some point, and hardly anyone will write in the documentation “This only works if your JSON doesn’t have keys called -”, so the program has some subtle bug in it that some poor user’s gonna have to deal with, far removed from the source of the issue. The alternative would be writing your own marshaller every time you deal with potentially arbitrary data, which in my view defeats the purpose of having a default one at all.
This was great. I’ve been using Go for >5 years and I learned a few things. The http context cancellation thing has bitten me but I didn’t know why until now.