Understanding enum implementation performance in Go

Posted on Apr 16, 2023

I’ve been writing enum implementations in Go in a certain way for a lot of time. It looks something like:

type Day uint8

const (
  Monday Day = iota
  Tuesday
  Wednesday
)

var dayValues = []string{"Monday", "Tuesday", "Wednesday"}

func (d Day) String() string {
  return dayValues[d]
}


And if values were not continuous I would just use a map[string]Type like what I proposed a few days back in a grafana/k6 issue:

// systemtag.go
package systemtag

type SystemTag uint32

const (
  TagProto SystemTag = 1 << iota
  TagSubproto
  TagStatus
)

var tagToValue = map[SystemTag]string{
  TagProto:    "proto",
  TagSubproto: "subproto",
  TagStatus:   "status",
}

func(st SystemTag) String() string {
  return tagToValue[st]
}

The response about performance of that approach from a maintainer made me actually benchmark it.

As a developer, the jump from a slice of strings to a map of strings was not that big, in my head the performance hit would not be really important. Yet, it definitely is and looking in the stdlib, I found there’s a different approach that does not provides the speed of a slice but it’s pretty close.

The net/http package implements this for returning the string values of HTTP status codes. It’s > 10x faster than a map of strings and taking into account how little are enum implementations updated, I think it’s worth to understand and know when it’s a good idea to use. The solution is using switch:

package systemtag

type SystemTag uint32

const (
  TagProto SystemTag = 1 << iota
  TagSubproto
  TagStatus
)

func(st SystemTag) String() string {
  switch st {
    case TagProto:
      return "proto"
    case TagSubproto:
      return "subproto"
    case TagStatus:
      return "status"
    default:
      return ""
  }
}

The results were compared against a complex approach generated by the enumer tool, using a combination of a single string with all the values and a unsigned integer slice for indexes to retrieve data from that string.

A snippet of the results:

goos: darwin
goarch: arm64
pkg: github.com/nrxr/enumbenchmarks/enumer/executionstatus
BenchmarkExecutionStatus_String_FirstItem-8    	1000000000	         0.9775 ns/op
BenchmarkExecutionStatusString_MiddleItem-8    	132739348	         9.036 ns/op
pkg: github.com/nrxr/enumbenchmarks/combined/executionstatus
BenchmarkExecutionStatus_String_LastItem-8     	1000000000	         0.5797 ns/op
BenchmarkExecutionStatusString_LastItem-8      	440918524	         2.727 ns/op
pkg: github.com/nrxr/enumbenchmarks/idiomatic/executionstatus
BenchmarkExecutionStatus_String_MiddleItem-8   	1000000000	         0.6257 ns/op
BenchmarkExecutionStatusString_LastItem-8      	439873963	         2.725 ns/op
pkg: github.com/nrxr/enumbenchmarks/manual/executionstatus
BenchmarkExecutionStatus_String_LastItem-8     	1000000000	         0.5239 ns/op
BenchmarkExecutionStatusString_LastItem-8      	171471234	         6.886 ns/op

The conclusion is that using the slice of strings is preferable but to do the inverse operation the switch case will yield better results. The switch case is more versatile if you want to keep consistency in approaches with all your code.

Both options are easier to maintain than the enumer approach and are better at performance.

I wrote a Git repository with all the implementations, the benchmark tests and comparisons between the implementations’ performance, in case you’re looking for more.