Go, the good, bad and ugly

Simplicity as a feature, and sometimes a flaw

Go, sometimes called Golang to make it searchable, was born at Google out of frustration with the languages already on offer. The brief was unusual: build something deliberately small. Where most languages accumulate features over time, Go’s designers spent their energy leaving things out. The result is a language you can learn in a weekend and read at a glance, and one that occasionally makes you wish it would just let you do the clever thing. Simplicity is Go’s defining virtue and its defining limitation, often in the very same line of code. Here is the good, the bad, and the ugly.

Go appeared in 2009, designed by a team that included veterans of Unix and C. They were building large systems with large teams, and they had grown tired of slow compiles, tangled dependencies, and languages where a single feature could be written five different 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, where a great deal of the modern stack, container runtimes, orchestration, and networking tools, is written in Go. To understand the language, it helps to remember that it was built for big teams shipping servers, not for individual virtuosos showing off.

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.

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 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.

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 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.

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.

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.

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.

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.