Why I'm no longer betting on Go

It's not you, it's me...mostly. —

The views expressed here are my own and don't reflect those of my employer.

I started writing Go in 2014, right before 1.3 was released. At the time, I was building embedded autopilot software in C++, Rust seemed like another project Mozilla was wasting donations on, Microsoft was still considered evil, Google was cool, and Kotlin’s website screamed vaporware.

The autopilot needed a control server to remotely gather stats and do file transfers so I whipped one up in Go in an afternoon. Unlike the autopilot’s C++, it compiled fast (not for lack of Makefile hacking), the standard library was modern, and there were no mystery segfaults.

However, as my career progressed and I began to do more software engineering – a.k.a. programming over time – in Go, it has become harder to justify as a platform for new development.

Business software must change over time if it’s going to continue to be safe, useful, and correct. The reasons are varied: CVEs, upstream library changes, new platforms, language evolution, business requirements, etc. But, the end result is somebody jumping into the code to patch things, often under time pressure and with limited context.

A good patch should have few defects and will be easily verifiable. But, in Go’s quest to be a simple and fast language it has chosen to force mundane work best suited for compilers onto the developer making changes more prone to defects and harder to verify.

Here are some examples, each of which has caused me to release defects to production:

  • The lack of constructors, no interface defaulting, and capitalization based visibility (sorry to the folks who don’t use the Latin alphabet) means small changes can have large diffs, even if there’s no change in behavior. This makes reviewing changes and understanding source control history harder.
  • The lack of enumerations and support for compile time checking means it’s easy to create bugs by missing cases when adding new values.
  • String based field tagging isn’t compile time checked in the same way as annotations/attributes in other languages. If your tag josn:"my-field" doesn’t get caught in code review then your JSON is going to be malformed.
  • Late introduction of generics means a mountain of mostly copy/pasted or generated code – or giving up on type safety like std/container. It also means each package has to define its own semantics to handle object comparisons, collections, or iteration (where even Go’s database/sql and archive/tar disagree!).
  • //go:generate is a significant regression compared to annotations/attributes. Because it’s a separate tool rather than a compilation step, the commands have no compile time context limiting what they can do unless they implement significant portions of a compiler themselves. Additionally, the produced results go stale if someone forgets to generate before each build and commit or has different versions of software on the machine running it.

This doesn’t mean Go is bad, in fact, most of the above limitations can be attributed to the design goals of the language. For example, default functions on interfaces don’t play well with structural typing, type-safe iterators need generics, and compile time plugins slow down compilation. The question is ultimately: are Go’s benefits worth the trade-offs required to achieve its goals?

Many of Go’s killer features like standalone binaries, cross-compilation, and improved concurrency1 have been adopted by its competition in the same time that it struggled to put out generics (something its users have been requesting for years). It’s niche is eroding and the contenders in its space don’t require compromises like the list above.

That’s why I’m no longer betting on Go – it was fun while it lasted.

  1. While not entirely equivalent, async/await/tasks in C# and Java, coroutines in Kotlin. ↩︎