I Shouldn’t Like Go (but I do)

Mar 11, 2025 23:06 · 2915 words · 14 minute read

Since I started programming 18 years ago I’ve written code in a lot of languages. I’ve even written some. I’ve always been fascinated with them: ergonomics, learning about different paradigms and tradeoffs. My favourite ones over the years have been Ruby, TS, Kotlin and Rust. I love Ruby’s elegance and meld of OOP and FP, I love TS’s amazing type system, I love Kotlin’s structured concurrency, I love Rust’s approach to static verification.

So, naturally, when we were discussing to use Go for a new project, I was opposed. I didn’t like the language. Every part of me screamed that it hated the language. Compared to the modern “luxurious” languages I was used to, it was like being forced to use that crude hacked-together “C with garbage collection” language that ignores 10 years of language ergonomics improvements and has edges so rough you have to resort to linters for validating correctness.

What was there to like? The swiss-cheese-like type system? The overly-verbose error handling? The goto-like concurrency primitives? The “I have my own way of doing everything” attitude, like how it just decides to use a 2006-01-02 15:04:05 as time formatting strings (yes, this exact string is what you provide to time.format instead of YYYY-MM-DD HH:mm:ss)? I can go on and on pointing out flaws in the language.

I see the appeal of the ecosystem, of course. Performance (CPU/memory) at not too much extra code verbosity (compared to something like TypeScript). Simpler tooling (compared to TS, Java/Kotlin), libraries that don’t change as frequently (you know compared to what). Hell, just not using npm should be reason enough.

I liked the ecosystem & tooling philosophy. I just hated the language.

So it’s weird to me that I actually like writing code in the language, even if I still think it shouldn’t be used in most projects by default.

Go is Easy, Programming is Hard

As a language, Go has a minimal set of features, so it’s easy to learn. Hell, it even uses capitalization for public/private visibility. However, make no mistake - learning the language doesn’t mean you’ll be effective with it quickly. C is also a simple language, but you don’t run around telling people that C is easy to learn, implying that it’s easy to start doing real work with.

Go doesn’t do any hand-holding. If you aren’t 100% sure what you’re doing, it will cut you. It relies on you, the programmer, to be more responsible than you need to be in most other languages. If you make a mistake in any other modern language, it will stop you and tell you. Go will assume you know what you’re doing and will allow you to fuck it up. And you can fuck it up more royally given the concurrency is not the guard rails single-threaded one like in Node, but a real, sharp-edged full-threaded one where you actually need mutexes, semaphores, waitgroups and concurrent data structures.

And that’s what makes it fun. It treats you like an adult. It treats you like you would be treated by C, with the (albeit dulled a bit) “segmentation fault” experience when you forget to provide a pointer to a struct somewhere. And the type system won’t save you. What will save you is only you carefully considering every possibility, thinking about edge-cases and having a structured approach to programming, plus a full suite of tests. The only thing that will save you is actually being a good programmer and knowing exactly what you’re doing.

There’s this joke about Rust that “Rust is not hard, programming is hard, Rust just enforces it at compile-time”. Go is a simple language, but programming is still hard. Go has some cool concurrency primitives, but concurrent programming is still hard. In fact, programming is even harder when the safety net your language’s type system provides you is a bucket of water at the bottom of the 10-story building you have to jump from.

I shouldn’t like it. But I do. It’s like driving a sports car vs taking a taxi. It gives you that adrenalin boost, makes you feel important, forces you to think about all the hard problems you usually only encounter in the odd side-project. It’s fun to figure out the best, most fluent way to express something with the crippled type system, to write maintainable code when any struct field can be accidentally left zero-intialized by anyone. It makes you think about every little thing you’re doing, but it doesn’t get in the way - it’s up to you to make sure you’re correct. Up to your colleagues to come to the code review prepared with a notepad and a microscope.

I actually love the “not getting in the way” attitude, but I also understand it’s a downside. If you’re experienced and know exactly what you want - it makes you more productive. But it also removes the guardrails, so if you have programmed in, say JS, for most of your career, you will feel very uncomfortable, like there is a bug hiding at every corner.

Figuring it All Out by Yourself

I love documentation. Yes, I’m weird and don’t represent the majority of software engineers, at least according to the memes I keep reading. But I love it when I can scroll through the documentation of a library and learn the ins-and-outs, any edge-cases I have to consider and any hidden functionality that might be useful at some point.

Go has some libs in which you can do that. But really, most of the libraries just have 2 paragraphs at the start of the docs, then a single example and an index of methods, with 2 lines of comments above each method. Sometimes some behavior is not at all mentioned in the documentation, sometimes you have to hunt for the place where it’s mentioned. If you use Go, you have to be comfortable reading library code, or you’ll be wasting time experimenting and throwing random solutions at problems. To be fair, I have frequently needed to read JS and Java library code as well, just for more obscure questions.

That said, it makes you a better programmer to be able to understand the code of libraries you use. Treat them like “someone gifted me this code, it’s written by a person, they may have made a mistake” instead of a black-box “this is a uber-library written by perfect aliens to transcend our material existence”. This is a real skill that saves real time later spent in debugging. And given that libraries are included as code and not pre-compiled binaries, you can easily “Go to definition” to a library function and answer your own question (that the documentation didn’t). This is great and I love it. Ruby can’t even tell you where a method comes from unless you run the code, TS can only jump you to a type definition and Kotlin has to decompile a class file or try to go and find the sources somewhere. With Go you just have the code there at the click of a button.

Doing it Yourself

Go (the language), the standard library and the ecosystem around it want you to do things yourself. They will give you the necessary tools, but you have to do the things yourself. In fact, it gives you a ton of tools in the standard library. Where in Node you have to pick an HTTP server router, in Go you have it by default. Where in Node you have to pick up an SQL client lib, Go has that built-in. And just like in Node, you have to build yourself a framework. Like in JS-land, code organization is your own responsibility. You are the programmer - if you don’t do it right you will suffer your own consequences.

I love inventing my own things and writing my own “libraries”. Needless to say, Go’s philosophy scratches that itch a lot. It’s not only the “not invented here” syndrome (although inventing it here is fun), it’s also that chances are you will find a library for the thing you want, but it will be unsupported or will not have the exact feature you need.

Library simplicity sometimes comes at the cost of features. The feature you need that 90% of the people don’t need? Well, it’s not in the library, so you’ll have to do it yourself. And who says you can’t “steal” some MIT-licensed code from the library? But there is a big upside - that code you now wrote - it’s now your code that you maintain on your own terms. It won’t randomly change major version breaking your code, it won’t randomly become bloated as other people are requesting features for use-cases you don’t have, it won’t be involved in a security issue that you don’t know about. And yes, that all relies on you being a good and careful programmer with a lot of background knowledge, not just on Go but programming in general. Otherwise the library is going to be better, if you can find one that matches what you want to do.

Concurrency in Go is like…

…flying a very powerful and modern fighter jet 10m above the ground while being upside-down. Needless to say, there is no autopilot on that plane. And that’s exactly what makes it so fun.

I love concurrency. I even wrote a master’s thesis on comparative analysis of different concurrency and parallelism approaches.

Go’s concurrency primitives are extremely powerful and elegant while simultaneously being very unsafe unless you are extremely careful (see a pattern?).

I love the goroutine approach to async programming (really called stackful coroutines but we’re Go people so we have our own names for stuff). It’s a breath of fresh air compared to the standard “async/await”. A function in Go does not have to specify whether it’s sync or async. It doesn’t have to return promises. It doesn’t have to get rewritten by the compiler into a state machine (looking at you, Rust, C# and Kotlin).

As much as I love the structured concurrency in Kotlin, it still has suspend and non-suspend functions, which behave differently and have to be handled differently. Go just has functions. One type. Simple. Functions are scheduled on OS threads and can suspend at any point - just like real OS threads, except more lightweight. So one coroutine can’t block other coroutines from progressing by hogging the CPU like it does in other (cooperatively-scheduled) models.

Unfortunately, due to the full concurrency and the similarity to threads, (1) you need to manage goroutine lifetime yourself so that you don’t end up creating orphaned goroutines everywhere, and (2) you need carefully use locking or suffer eternal damnation data races. Where Kotlin will stop you from creating a coroutine that lives more than your function, Go will just let you do it. Where Rust will make you prove to it that a value can safely be shared between threads, Go will just let you do it. And when you have it all planned in your head and just want to write it - Go just gets out of the way and lets you do it.

When in Rome…

The API interfaces are also much much simpler in Go-world. Sometimes this is a pain, but most of the times it’s pretty powerful. In fact, Go tries to force you into creating simple interfaces - because the language is just not that expressive. It didn’t have generics for a long time, for God’s sake. Even now that it does - you can’t have a generic method - only non-method functions. So you can’t do

db := OpenDB(...)
result, err := db.InTransaction(func() (SomeType, error) {
  ...
})

because then InTransaction would be a generic method (generic on the return type). You need to do:

db := OpenDB(...)
result, err := InTransaction(db, func() (SomeType, error) {
  ...
})

But it does make sense. I have worked on implementing a language with a similar type system and it does make sense that it is hard to implement.

Not allowing you to easily do complicated things is one of the language’s biggest strengths. It’s a contrarian take on the ever-increasing complexity that the software world suffers so much from. Of course, a misguided but dedicated Java programmer given enough time will recreate all the OOP FactoryProducerImpl hierarchy you can dream of, but I would advise that when you’re in Rome you should do as the romans do.

Go’s philosophy is that you should write the simplest and most straightforward code possible. And I’m completely on board. There are places to have a generic solution, and I love abstraction as much as everyone but most code doesn’t call for a SpecificThing interface with a SpecificThingFactory and a single SpecificThingImpl class. Structure your “framework” code well, make that generic, then write your business code as if you’re going to have to explain it to your grandma over the phone later. If you need to change it later due to changing requirements - it’s your code - go ahead and change it, run your tests again, add a couple more and you’re done.

But keep in mind that simple code doesn’t mean “code lacking structure” and “one 200 line function”, it just means “use less abstraction than you feel you need”. Go programmers don’t like overly generic and abstract code - the language will fight you on it. And that’s a good thing.

Performance by Just Doing Less Things

There are many ways in which programming languages try to make your code performant.

JIT-compiled languages, for example, will try to make your code faster by making the language runtime & compiler more complex, trying to predict types of values, optimizing-away code, essentially reversing code genericness at runtime to gain back performance. In Java all methods are virtual, thus overridable by default. Which means you have dynamic dispatch everywhere. Which the JIT then tries to optimize into static dispatch at runtime to gain back some (in some cases a lot of) performance. They trade runtime complexity for performance.

Statically compiled languages like Rust or C++ will try to improve performance in a different way - they try to make zero-cost abstractions, ones that are generic and abstract to program with, but which get optimized out at compile time. Unfortunately, in a lot of cases that results in a more verbose language and weird limitations. An example for this is Rust’s borrow checker. I love the borrow checker but it does limit expressiveness and disallows some valid programs to achieve its zero-cost GC alternative.

What Go does is something different - instead of introducing abstractions and complexity, only to try to mitigate it later through clever tricks - it tries to be as transparent as possible. You have very few, very explicit tools for abstractions (interfaces, closures), and even the standard library tries to be as straightforward as possible. And that is taken to the extreme - it doesn’t even have an Optional type.

Where Java has to pay the wrapper cost of using Optional and Rust has to apply clever tricks to essentially turn its Option into a null value, Go just doesn’t have one and that’s by design.

Where Java and TS have to pay the cost of dynamic dispatch and then optimize it at runtime by a JIT, Go just has static methods and interfaces - it’s your choice if you want static dispatch or dynamic one. And you know exactly what code will be generated, no need to try to “please” the JIT.

Where Java and TS have to pay the cost of pointer indirection for every object, trying to recover it by inlining through the JIT, Go just lets the programmer decide if something should be a value or a pointer.

Where Java and JS pays for exceptions and then tries to optimize them, Go just returns an error return value.

In essence, Go’s philosophy on performance is “less things to do = more performance” and “the more transparent the generated code is to the programmer, the better”. The programmer knows best. Go achieves its performance by just doing less things in the first place. That’s why Go has pointers - it’s a fundamental primitive of the actual CPUs we use to run the code, just as much as a “segmentation fault” is a fundamental primitive of the same.

I’m not trying to say that Go has it completely figured out - nil pointers are definitely an issue, and having nullable types in the type system will be a godsend, but this illustrates their fanatic praise of transparency and simplicity, which to be honest is a breath of fresh air. Programming has become too abstract and too complicated.

I Like Go (but maybe I shouldn’t)

From a language design standpoint, Go is a bad language.

From a pragmatic standpoint - it gets the job done well.

From an ideological standpoint - we need more of Go. Our profession is running towards ever-increasing complexity that we try to handle with even more complexity. We’re now so many abstraction layers away from the machine that fewer and fewer people actually understand how things work. And our solution is to try to make black-box LLMs abstract this even further away from us? Soon only LLMs will be able to do anything with computers. Seems eerily similar to Idiocracy where machines handle everything and people have devolved into pure consumers.

Unless we do something about it. Go does something about it.