Some of my favorite tidbits from the past year of working with Go.
One of the best ways to learn something new is to write down something you’ve learned about it regularly. Over the past year, I’ve been doing this with the Go programming language. Here are some of my favorite lesser-known tidbits about the language.
Ranging Directly over Integers
As of Go 1.22, you can range over an integer:
Renaming Packages
You can use Go’s LSP to rename packages, not just regular variables. The newly named package will be updated in all references. As a bonus, it even renames the directory!
Constraining Generic Function Signatures
You can use the ~ operator to constrain a generic type signature. For instance, for typed constants, you can do this:
This is really useful when the concrete type is a typed constant in Go, much like an enum in another language.
Index-based String Interpolation
You can do indexed based string interpolation in Go:
This is helpful if you have to interpolate the same value multiple times and want to reduce repetition and make the interpolation easier to follow.
The time.After function
The time.After function creates a channel that will be sent a message after x seconds. When used in combination with a select statement it can be an easy way of setting a deadline for another routine.
The embed package
The “embed” package lets you embed non-Go files directly into the Go binary. There is no need to read them from disk at runtime.
You can embed HTML, JS, even images. Compiling assets directly into the binary can make deployments significantly simpler.
Using len() with Strings, and UTF-8 Gotchas
The len() built-in in Go doesn’t return the number of characters in a string, it returns the number of bytes, as we cannot assume that string literals contain one byte per character (hence, runes).
Runes correspond to code points in Go, which are between 1 and 4 bytes long. As an additional complexity, although string literals are UTF-8 encoded, they are just aribtrary collections of bytes, which means you can technically have strings that have invalid data in them. In this case, Go replaces invalid UTF-8 data with replacement characters.
Nil Interfaces
What do you think this prints?
The answer: is false!
This occurs because even though the value is nil, the type of the variable is a non-nil interface.
Go “boxes” that value in an interface, which is not nil. This can really bite you if you return interfaces from functionsl Once you return that value, even if it’s nil, if you’ve typed the return value as an interface. The nil check no longer works as you’d expect.
In this example, c is a boxed nil value, therefore, the check c == nil will always be false.
Invoking Methods on Nil Values
Relatedly, you can actually invoke a method on a nil struct. This is valid Go:
It should go without saying, of course, that attempting to access a property on this would still panic.
Variable References with Ranging over Maps
When updating a map inside of a loop there’s no guarantee that the update will be made during that iteration. The only guarantee is that by the time the loop finishes, the map contains your updates.
For instance, in the above code, you may or may not see the values we add inside the loop printed.
This is because of the way objects are managed internally in Go. In Go, when you add a new key/value, the langauge hashes that key and puts it into a storage bucket. If Go’s iteration has already “looked in” that bucket in the object, your new entry won’t be visited in the loop.
This is different than, for instance, python, which has a “stable insertion order” that guarantees that this won’t happen. The reason Go does this: speed!
Returning Custom Errors
It’s often helpful in Go to return unexpected errors as typed errors, so that you can provide additional context for debugging or other upstream uses. Defining them as types lets you attach structured data via errors.As, and implement custom logic, while still satisfying the error interface.
Context-Aware Go Functions
In a function that’s context aware you should always select on the context, as well as the channel. This is because you may end up waiting needlessly for that operation to complete despite the context being cancelled.
For instance, in the below example, we either send to the channel an “operation complete” message when a time.After finishes, or we exit early on context cancellation.
Since our sendSignal function detects the context cancellation, we’re able to exit early.
This is a good example of why you need to select on context in channel operations - without it, the function would wait the full 5 seconds, even though the context was cancelled after 1 second.
Bonus fact: the Go context is cancelled after an HTTP handler finishes and the response is fully sent, even in success responses. This is why you need to be careful with context propagation. For instance you can introduce race conditions by passing along context to an event publisher from an HTTP request, where the fast HTTP response can cancel the propagated context and prevent the publication of the event.
Empty Structs
You’ll often see Go developers sending empty Go structs around. Why this, and not say, a boolean?
In Go, empty structs occupy zero bytes. The Go runtime handles all zero-sized allocations, including empty structs, by returning a single, special memory address that takes up no space.
This is why they’re commonly used to signal on channels when you don’t actually have to send any data. Compare this to booleans, which still must occupy some space.
The Go compiler and the range keyword
The Go compiler “lowers” the range keyword into basic loops before Go is further compiled. The implementation is different depending on what’s being lowered, e.g. a map, slice, or sequence like from the iter package.
Interestingly, for the iter package, it actually translates a break call inside of a range to the “false” that would be returned typically by the yield function to stop iteration.
Hidden interface satisfaction causes issues with struct embedding.
Say, for instance, you embed a time.Time struct onto a JSON response field and try to marshal that parent.
When you embed structs, you also implicitly promote any methods they contain. Since the time.Time method has a MarshalJSON() method, the compiler will run that over the regular marshalling behavior.
In this example, the Event struct embeds a time.Time field. When marshalling the Event struct to JSON, the MarshalJSON() method of the time.Time type is automatically called to format the entire result which ends up not printing what you’d expect.
This is true of other methods too, which can lead to weird and hard to track down bugs. Be careful with struct embedding!
The ”-” tag for JSON
Using the ”-” tag when Marshalling JSON will cause the field to be omitted. This is nice if you have private data on a field that’s meant to be excluded from an API response.
This is a contrived example, you obviously wouldn’t be this careless with a password. But it’s a handy feature nonetheless.
Comparing Times
When converting a Time in Go to a String, the stringer automatically appends timezone information, which is why string comparisons won’t work. Instead, use the .Equal() method, which compares times: “Equal reports whether t and u represent the same time instant. Two times can be equal even if they are in different locations.”
This often comes up in testing and continuous integration.
The wg.Go Function
Go 1.25 introduced a waitgroup.Go function that lets you add Go routines to a waitgroup more easily. It takes the place of using the go keyword, it looks like this:
The implementation is just a wrapper around this: