Go, the good, bad and ugly

Simplicity as a feature, and sometimes a flaw

Contents

The first time I shipped a Go service it was a single 12MB binary copied onto a bare VM with scp, and it just ran. No runtime to install, no virtualenv to source, no version of some interpreter that was subtly wrong on the target box. After years of fighting that exact class of problem in other languages, the experience was almost suspicious. That is the thing about Go: it solves real, boring, expensive problems, and it does so by refusing to be clever. The same refusal that makes deployment trivial is the one that has you typing if err != nil for the four-hundredth time and quietly resenting it. Simplicity is Go’s defining virtue and its defining limitation, frequently in the very same line of code. Here is the good, the bad, and the ugly, from someone who keeps reaching for it anyway.

A Little Context

Advertisement

Go appeared in 2009, designed by a team that included Robert Griesemer, Rob Pike and Ken Thompson — the last two being veterans of Unix and C, with Thompson having co-created both. The legend, which the designers have repeated often enough that I believe it, is that the language was conceived while waiting for a large C++ program to compile. They were building enormous systems with enormous teams, and they had grown tired of slow builds, tangled dependencies, and languages where a single idea could be expressed five incompatible ways. Go was their answer: fast to compile, easy to read, and stubbornly opinionated about how things should be done.

It found its natural home in cloud infrastructure. Docker, Kubernetes, Prometheus, Terraform, etcd, CockroachDB — a startling amount of the modern stack is written in Go, which is no accident given the language came out of Google around the same time the industry was learning to live in containers. If you self-host anything, you are almost certainly running Go binaries you never think about. To understand the language, it helps to remember it was built for big teams shipping long-lived servers, not for individual virtuosos showing off. That single design decision explains nearly every choice that follows, the delightful ones and the infuriating ones alike.

The Good

The headline feature is simplicity. The language specification is short, the keyword list is tiny, and there is usually one obvious way to do a thing. A new engineer can become productive in days, and code written by someone else reads much like code you would have written yourself. That uniformity is wonderfully boring, in the best sense.

Compilation is fast. Go was designed so that even large codebases build in seconds, which keeps the edit-compile-run loop tight and your concentration intact. Then there is concurrency, Go’s genuine party trick, expressed through goroutines and channels.

1
2
3
4
5
func main() {
    ch := make(chan string)
    go func() { ch <- "done" }() // a goroutine, lightweight as can be
    fmt.Println(<-ch)            // wait for the result
}

A goroutine is a function running concurrently, scheduled by the runtime, costing a tiny fraction of an operating-system thread. Channels let goroutines communicate safely without manual locking. Spinning up thousands of them is entirely ordinary.

Go also compiles to a single static binary: no interpreter to install, no dependency hell on the target machine, just one file you copy across and run. That makes deployment almost comically simple, which is a large part of why Go dominates cloud tooling. Add a famously rich standard library — an HTTP server, JSON handling, and crypto all built in — and excellent tooling (go fmt, go test, go vet) baked into the toolchain, and the day-to-day experience is smooth. The contrast with the npm ecosystem is stark; if you have ever watched a JavaScript project pull six hundred transitive dependencies for a date formatter, the appeal of a fat standard library is obvious. I unpacked exactly that pain in JavaScript, the good, bad and ugly, and Go is in many ways a deliberate reaction to it.

Crucially, go fmt ends the formatting argument before it starts. There is one canonical style, the tool enforces it, and nobody bikesheds over tabs versus spaces in code review. Coming from the wild west of Bash, where every script is its own dialect, the enforced uniformity is a genuine relief.

The Bad

Advertisement

The most cited complaint is verbose error handling. Go has no exceptions; functions return errors as ordinary values, and you are expected to check every one.

1
2
3
4
5
f, err := os.Open("file.txt")
if err != nil {
    return err
}
defer f.Close()

That pattern is explicit and honest, and it is also everywhere, sprinkled through every function until the actual logic feels buried beneath the checking. Then there is the matter of generics, which Go lacked for over a decade. They finally arrived in 2022, but their long absence forced years of repetitive code or awkward use of the empty interface, and the historical scar tissue lingers in many older libraries.

Go is opinionated to a fault. The compiler rejects unused variables and unused imports outright, which is tidy in production and maddening while you are debugging and just want to comment a line out. There is no ternary operator, no method overloading, no inheritance; the language has decided these are bad for you. Often it is right. Occasionally you simply disagree, and Go does not care. And while the garbage collector is impressively low-latency, GC pauses can still matter for the most demanding real-time workloads, where any unpredictability is unacceptable.

The Ugly

The truly ugly bits start with if err != nil as a way of life. Taken individually each check is reasonable; taken across a whole codebase the repetition becomes a kind of background noise that every Go programmer has strong feelings about.

1
2
3
4
5
6
a, err := step1()
if err != nil { return err }
b, err := step2(a)
if err != nil { return err }
c, err := step3(b)
if err != nil { return err }

Three lines of work, six lines of plumbing. Worse is the nil interface gotcha, a trap that catches nearly everyone at least once.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type MyError struct{}
func (e *MyError) Error() string { return "boom" }

func doThing() error {
    var p *MyError = nil
    return p          // surprise: this is NOT a nil error
}

if doThing() != nil {
    // this branch runs, even though p was nil
}

An interface holding a nil pointer is not itself nil, because it carries a type alongside the value. The comparison against nil therefore fails, and a function that “returned nil” trips an error check anyway. It is logically defensible and practically a landmine.

Then there is the module and dependency history. Go’s package management was a long and painful saga, from GOPATH, which forced your code into a rigid directory layout, through a parade of third-party tools, to the modern go mod system that mostly fixed things. Mostly. Anyone who worked with Go before modules still flinches at the memory, and projects that span that transition carry the scars.

Gotchas and how to dig yourself out

A few traps catch nearly everyone, and knowing them in advance saves an afternoon of confusion.

The nil-interface trap (covered below in The Ugly) is the famous one. The defensive habit: return the concrete error interface type from functions, and never return a typed nil pointer where an error is expected. If you must, return an explicit nil literal:

1
2
3
4
5
6
7
func doThing() error {
    var p *MyError = makeIt()
    if p == nil {
        return nil      // return the literal, not the typed nil pointer
    }
    return p
}

Loop-variable capture bit everyone for years. Before Go 1.22 each iteration of a for loop reused the same variable, so a goroutine closing over it saw the final value, not the value at launch:

1
2
3
for _, v := range items {
    go func() { fmt.Println(v) }()  // pre-1.22: prints the LAST item N times
}

Go 1.22 (early 2024) changed loop semantics so each iteration gets its own variable, which quietly fixed a decade of bugs. If you are on an older toolchain, the fix is the old idiom v := v at the top of the loop. Run go version before you trust the new behaviour.

Nil map writes panic. A var m map[string]int is nil and reads return the zero value, which lulls you, but writing to it crashes with assignment to entry in nil map. Always m := make(map[string]int) before you write.

Goroutine leaks are the silent killer in long-running services. A goroutine blocked forever on a channel nobody will ever send to never gets collected; it just sits there holding memory. If your service’s memory creeps upward over days, suspect a leaked goroutine before you suspect the garbage collector. runtime.NumGoroutine() and the pprof goroutine profile (import _ "net/http/pprof") will show you the count climbing. This is exactly the kind of slow bleed you want a dashboard watching — the sort of thing I monitor with the stack from Grafana and Prometheus: a monitoring stack that scales down.

Who Go Is Really For

Go knows exactly what it is for, which is part of why it feels so coherent. It was built for backend services and infrastructure written by teams, and that is precisely where it excels. If you are writing an HTTP API, a command-line tool, a network proxy, a message-queue consumer, or anything that needs to handle many connections at once and deploy without fuss, Go is close to an ideal fit. The single binary drops onto a server or into a container with no runtime to install, the standard library already contains most of what you need, and the concurrency model lets you scale to thousands of simultaneous operations without summoning the dark arts of thread management.

There is a deeper philosophy at work here, and it is a social one as much as a technical one. Go optimises for the team and the long term rather than the individual and the moment. Because there is usually one obvious way to write something, code looks broadly the same regardless of who wrote it, which makes large codebases legible and onboarding fast. The price is that Go rarely lets you be clever, and developers who relish expressive, feature-rich languages can find it constraining to the point of dullness. For number-crunching scientific work, for highly abstract domain modelling, or for low-level real-time systems where every microsecond of GC pause is unacceptable, other languages will serve you better. But for the unglamorous, load-bearing plumbing of the internet, Go is hard to beat, and “boring” turns out to be high praise.

The Verdict

Go is what you get when a language treats simplicity as the highest virtue and is willing to pay for it. The payoff is genuine: code that any team member can read, binaries that deploy without ceremony, concurrency that is approachable rather than terrifying, and compile times that respect your attention span. That combination is precisely why so much of the cloud runs on it.

The cost is equally genuine. You will write if err != nil until it haunts your dreams, you will occasionally bump into the nil-interface trap, and you will sometimes wish the language trusted you with a clever shortcut it has decided you cannot be trusted with. None of this is incompetence; it is the deliberate trade Go made. For network services, command-line tools, and infrastructure built by teams that value clarity over cleverness, it is an excellent trade. For everything else, your mileage, as ever, depends on how much you mind typing the same six lines again.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.