Thoughts on Golang
Over the last year, I’ve had the opportunity to work a lot with Golang code. It has certainly been an interesting experience, as I’ve never seriously used Go before.
Quick overview
Golang is a programming language created by Google. It’s fairly low-level and compiles to machine code, but is designed for general-purpose use and has automatic memory management.
Paradigm
Golang feels like a piece of software designed with system-driven design rather than user-centred design. The stated design goal is to keep the language simple. This has both positive and negative results.
Firstly, I disagree with the implied statement that a simple language is an imperative language. It’s possible to write a language that is no more complex than Golang which supports, at the very least, easy use of functional patterns mixed in with imperative code.
Tooling
The package manager
go.mod
is great. For those who are unfamiliar with it, Golang will simply refuse to link any packages into your code unless they are listed in the go.mod
residing in the root of your codebase. More languages should do this. Why? It prevents you accidentally introducting unwanted dependencies into your project.
go.sum
, meanwhile. I’m not a big fan of. It lists the checksums of all packages in the go.mod
file.
This is great for security reasons as it provides Trust On First Use security for all your dependencies. This means that if an upstream source becomes compromised and retags a version tag to some malicious code, you’re safe.
However, it’s quite frustrating to have to run a command to update it every time you modify your dependencies. It feels like this could be done automatically, and is only this way due to system-centred design. Of course you can call go get
but it’s slow with non-obvious syntax for getting a specific version.
Golang also compiles all dependencies locally. This is good although it can feel frustrating when it takes a long time to compile. But it’s worth it to avoid the likes of the xz-utils backdoor. I have no complaints here.
Containerisation
There are various approaches to building Golang code into a container.
You can go with the simple approach of making a Dockerfile that calls go build
, but then you end up needing to download the dependencies during every build process where a cache miss occurs.
Getting the Dockerfile to build quickly is rather tricky as Docker doesn’t make explicit caching sharing very easy, especially if you build multiple containers from the same source (or with common dependencies).
Since a Golang project contains (by definition, in the go.sum
) a hash of every dependency, the build process is quasi-reproducible1. This means that we can safely bypass most of the complicated work that Docker does to protect us from external influences during build.
And that’s exactly what Ko does. You simply declare the arguments for the compiler and it handles turning it into a Docker image, basically by running go build
on the host machine then copying into a container. It’s a very nice tool and speeds up the build process by an order of magnitude (in my case, over 3x speedup).
Linting
A wise man once told me “programmers should be lazy and stupid”2. The logic is that a “lazy” programmer won’t over-complicate things, and a good programmer should write code that “stupid” people can understand. What with agentic LLMs writing code, I suspect that code authors are going to get less lazy and more stupid. Since perhaps the most useful input to an agentic system is the criticism, a good linter is essential. Some of the linters in Golang are fragmented, slow and often wrong, which makes it difficult to use AI for development, but also slows down human work.
thelper
A common mistake I’ve seen (in human- and AI-written code) is to use t.Helper()
(from testing.T
) in test helpers.
T.Helper
has very precise criteria for correct use3, but the thelper
lint rule complains every time any function accepts a testing.T
parameter. This leads people to think they are making a mistake by not including t.Helper()
at the start of helper functions.
Test discovery
A common practice in Golang is to have dynamically-declared subtests (table-based testing). That’s a good thing in most languages, because it avoids code duplication. However, in Golang, the test names aren’t known until after you have started running them. In Java, for example, table-driven tests are brought up at class initialisation time which means they are discoverable. This means that Go subtests are a pain to discover4.
Memory management
Go uses a garbage collector for memory management, and as GCs go, it’s pretty good. It’s not very stop-the-world and can be tuned.
Race detector
The race detector is very helpful when testing. It’s very nice to get a warning when your code has a data race, and can save a lot of time debugging in production.
Code patterns
Golang’s language design brings about a number of code patterns that I dislike. Some of these are avoidable (and should be avoided!), but others we’re stuck with.
Slices
Golang has arrays (fixed length) and slices (variable length) which is slightly more complex than necessary but not bad (although I’ve seen it cause problems for new developers).
In the interests of keeping the language simple, Go has sacrificed the ability to cast slices.
That’s quite good because it means that every piece of syntax is constant time. Except when casting between string
and []byte
5.
Go has a very frustrating habit of making sensible choices and then giving them weird exceptions for convenience.
Another thing that annoys me is that a slice exposes its capacity all over the place. Surely the underlying capacity of a slice should be an implementation detail? If you need to ensure there exists an Nth element of a slice, you should request a specific length. If you want to request a specific capacity, fine. But you shouldn’t have to consider the capacity of the slice when slicing it. It’s simpler for Go but much more complex for everyone who uses Go. Another example of system-centric design.
Goroutines
I used to think goroutines were the scourge of the entire language. Now, I’m not so sure. I still think that structured concurrency is the way to go (no pun intended), but when used correctly, goroutines are fine.
Using errgroup
s and channels correctly can make goroutines less horrible to work with, but there are still many situations where it would just be so much eaiser to write correct code if we had structured concurrency.
Deferring
I strongly dislike the defer
statement. It makes it very hard to see where the code flow goes. There’s no alternative, sadly. I think that Python’s with
blocks are exemplary, as they effectively do the same thing but with two key advantages:
- you can use a
with
block to scope the cleanup code to something other than a function - a
with
block declares exactly what it scopes at the top, and always executes the same cleanup code at the end
Error handling
Go tries to be too simple here. These days, when I write error handling code in Go, I always wrap the error at every level. That means that I’m effectively creating a stack trace as the error unwinds. Just I have to write the whole thing myself. Every single time. If Go had automatic stack traces and automatic error propegation, it would be infinitely more pleasant to use. Once again this is an area where Rust excels.
Type system-related issues
Go has both typed and untyped constants. Once again it’s a simple concept that turns out to be quite annoying. An untyped constant has no type, so it implicitly converts to whatever type is needed, but it has a default type that it falls back to. This means that any function that implements a comparison gets messed up6.
Or how about taking a pointer to a value. In most languages (on the same level as Golang, that is) you can take a pointer of almost any value, but Go decides that all pointers must be writable. On the face of it this is a very attractice proposition because it eliminates a whole class of errors where we write to a pointer in rodata. But Go also doesn’t include any sort of Optional container7, so any optional field becomes a pointer. Which means that all optional fields are pass-by-reference and mutable.
And that brings me to my next point, the language has no concept of optional parameters. This can sometimes be worked around with pointers but it makes it very painful to call the function because all arguments must be declared as variables beforehand. So most people use the options pattern to get around this. Unfortunately this pattern requires loads of code, reduces discoverability of arguments and adds a level of indirection. If Go supported a simple, universal Optional type, that would mean we didn’t need to use pointers or options.
Still on pointers (wow I hate them), Golang supports nesting structs, and it can implicitly copy fields and methods from the inner struct to the outer one. Very neat. Here’s an example:
type Animal struct {
Species string
Name string
}
func (a Animal) PrintName() {
fmt.Sprintf("My name is %s", a.Name)
}
type Collar struct {
Label string
}
type Cat struct {
Animal
*Collar // pointer to make it optional; not all cats have collars.
}
Let’s ignore the convoluted example and focus on the fact that every Cat
contains an Animal
struct, which provides the cat’s name and species.
This means that we can call cat.PrintName()
and it will Just Work™. There is also an optional Collar
.
At this point, the cat may have a collar, which then contains a label. Golang, in an effort to be helpful, lets us call things like cat.PrintName()
, cat.Species
or (crucially) cat.Label
.
If we try to access Cat{}.Label
, the entire program panics with a null pointer deference error, because .Label
is inside the .Collar
which is nil
. That’s despite the fact that the type that we accessed is not a pointer!
I’ll be the first to admit that this is quite a convoluted example because you should never have an embedded struct that is a nullable reference. It’s a very bad idea. But Golang permits it and raises no warnings about it, and I’ve seen it crop up in the wild.
Standard library
Due to the long-standing lack of generics (now resolved), Go’s standard library is very sparse, and what it does do is often overspecialised. I think generics is a very good case of when less is actually less – without it you have no chance of making useful, safe, reusable functions.
Documentation
And don’t even get me started on how poorly Golang is documented. If you search virtually any Go-related query, the first result is always some website called golangdocs
. I don’t trust it.
Conclusion
While I have mainly focused on the downsides of Go, most of these can be avoided if code is written to sensible standards. It’s unfortunate that these standards have no specification or documentation, and mostly have no linters available (because I’ve skipped the mistakes that have linters available).
On the whole I think Go is a decent language although it can be very frustrating at times.
-
Even
//go:embed
isn’t able to include files from outside the package directory. Kudos to the Golang devs on this, it’s very clean. ↩ -
Russell Bradford was very clear on this in CM20318 at the University of Bath. ↩
-
It should only be used if the function could reasonably be called
AssertXxx
. For exampleAssertSuccess
should useT.Helper
whileSetupEnvironment
should not. ↩ -
For example, let’s consider the function
func eq(a any, b any) bool { return a == b }
. If we calleq(1, 2)
that will work fine. But after initialisingvar x = 1
,var y uint64 = 1
,eq(x, y)
returns false. This pattern turns out to be very common in tests, dueAssertEqual
functions. ↩ -
Like in Haskell or Rust (or even arguably Kotlin), where we can wrap a type in a thing that makes it nullable. ↩