Go Is a Language That Says No, and That Is Its Best Feature
Most languages grow by addition. A useful idea appears somewhere, users ask for it, and eventually it arrives. Over twenty years this produces languages with several ways to do most things, where a significant part of learning is working out which subset your team has agreed to use. Go was built by people who had watched that happen and decided to resist it. The design brief was explicitly about what to leave out. No inheritance. No exceptions. No operator overloading. No implicit conversions. No generics for the first decade, and when they arrived, a deliberately constrained version. The language is small enough that an experienced programmer can hold all of it in their head, and that was the goal rather than a side effect. This is a genuinely unusual position and it produces genuinely unusual consequences. Some are wonderful. Some are irritating. Almost all of them are the direct result of a decision somebody made on purpose, and understanding the reasoning turns Go from a language that seems oddly primitive into one that seems unusually honest. The problem Go was actually built to solve Go came out of a company with enormous codebases, thousands of engineers, and build times measured in a way that made people genuinely unhappy. The pain points were not expressiveness. They were compile speed, dependency management, the difficulty of onboarding somebody onto an unfamiliar codebase, and the cost of concurrency. Every major decision traces back to one of those. Compilation is fast because the language is simple and the dependency graph is strictly acyclic. Code is readable by strangers because there is one obvious way to write most things and a formatter that removes style as a topic. Concurrency is cheap because the runtime schedules lightweight goroutines rather than operating system threads. If you evaluate Go as a language for expressing elegant abstractions, it compares poorly. If you evaluate it as a language for getting a hundred engineers to produce a maintainable network service, the picture changes completely. It was designed for the second job. Errors are values, and you will handle every one This is the thing newcomers complain about most, so it is worth taking seriously. Go has no exceptions. A function that can fail returns an error as its last return value, and the caller checks it. The result is that Go code is punctuated every few lines by a three-line block checking whether the previous call worked. Critics call it noise. It is certainly repetitive. What it buys is that every failure path is visible in the source. There is no invisible control flow, no stack unwinding to somewhere you cannot see, no question about which layer will catch what. Reading a Go function tells you exactly what happens when each step fails, because the handling is right there. It also makes ignoring an error a visible act. In a language with exceptions, failing to handle one looks like nothing at all — the code simply does not mention it. In Go, discarding an error requires writing something that a reviewer can see and question. The modern idiom is to wrap errors with context as they travel up, so the final message describes the chain of operations rather than just the lowest-level failure. The standard library supports inspecting a wrapped chain for a particular error type, which covers the cases where exceptions would have been used for control flow. The honest counterpoint: this is genuinely more typing, and for a deeply nested call stack where nothing can be done locally, the repetition has no benefit beyond visibility. Reasonable people disagree about whether the trade is worth it. What is not in dispute is that it was a trade, made knowingly. Goroutines and channels Go's concurrency model is the feature that earns it a place even among people who dislike everything else about it. A goroutine is a function running concurrently, started by putting one word in front of a call. It is not an operating system thread. It starts with a very small stack that grows as needed, and the Go runtime multiplexes many goroutines onto a few real threads. Running hundreds of thousands is ordinary. Running a thread per connection is something no other mainstream language of its era made this easy. Channels are typed pipes between goroutines. The slogan that captures the intent is to share memory by communicating rather than communicating by sharing memory: instead of several goroutines locking a shared structure, one goroutine owns the data and others send it messages. The select statement waits on several channels at once, which is what makes timeouts, cancellation and fan-in natural to express rather than something assembled from primitives. The part people underestimate is that concurrency is still hard. Goroutines can leak if nothing ever reads from a channel they are blocked on. Closing a channel twice panics. Unbuffered channels block until both sides are ready, which is a deadlock if you expected otherwise. Go makes concurrency cheap and accessible; it does not make it automatically correct, and the built-in race detector exists precisely because mistakes are still possible. Interfaces are satisfied implicitly, and this is the clever part In most statically typed languages a class declares which interfaces it implements. In Go, a type satisfies an interface simply by having the right methods. There is no declaration, and the type need not know the interface exists. This inverts the usual dependency. You can define an interface describing exactly what your code needs, and existing types from any library satisfy it without modification. The interface belongs to the consumer rather than the provider, which means abstractions can be introduced after the fact rather than anticipated. It also drives the convention that Go interfaces are small — frequently one method. A one-method interface is easy to satisfy, easy to fake in a test, and composes well. The cultural guidance is to accept interfaces and return concrete types, so callers are not locked into an abstraction they did not ask for. Composition instead of inheritance Go has no class hierarchies. A struct can embed another struct, which promotes the embedded type's methods to the outer one, and that is the whole mechanism for reuse. Embedding looks like inheritance and is not. There is no overriding, no virtual dispatch through the hierarchy, no base class calling into a subclass. It is delegation with convenient syntax, which means the behaviour you see is the behaviour you get, and the chain of "where is this method actually defined" is short. For anyone who has untangled a five-level inheritance hierarchy to work out which override runs, the restriction reads less like a limitation and more like a relief. The tooling is part of the language Go ships with a formatter, and the formatter has no options. Every Go codebase on earth looks the same. Debates about brace placement and indentation are not resolved, they are prevented, and the time that would have gone into them simply does not get spent. The toolchain also includes a test runner, a benchmark runner, a race detector, a documentation generator, a dependency manager and a vetting tool, all built in. There is no choosing between competing tools, no configuring a build pipeline before writing code, and no situation where a new contributor cannot run the tests because of their setup. Compilation produces a single static binary with no runtime to install. Deployment is copying a file. For containerised services this is a genuine operational advantage — the image can be almost empty. Generics arrived late and deliberately constrained For a decade Go had no generics, and the standard answer was to use interface{} and type assertions, or to generate code. Both were unsatisfying and everybody knew it. Generics eventually arrived with a design that prioritises comprehensibility and compile speed over expressive power. There is no specialisation, no higher-kinded types, no operator constraints. You can write a generic container or a generic utility over comparable things, and that covers most of what people actually wanted. The reaction was instructive. Many expected an explosion of generic abstraction and it did not happen, partly because the design discourages it and partly because the culture does. Go's community treats a clever abstraction as a cost to be justified rather than a demonstration of skill, and that cultural norm does more to keep codebases readable than any language rule. Where Go fits, and where it does not Go is an excellent choice for network services, command-line tools, infrastructure software and anything where deployment simplicity and concurrency matter. A very large share of modern infrastructure is written in it, which is not an accident: container tooling, orchestration, observability and proxies all have the same shape, and Go fits that shape unusually well. It is a weaker choice where the domain wants rich abstraction. Complex business rules with many interacting types can feel verbose when expressed with Go's deliberately limited vocabulary. It is not a systems language in the sense of having no runtime — the garbage collector rules out the hardest real-time constraints. And it is not where the scientific or machine learning ecosystems live. The summary worth carrying: Go optimises for the reader, for the build, and for the operator. It does not optimise for the writer's expressiveness, and it does not apologise for that. Slices are not arrays, and the difference causes real bugs Go has arrays with a fixed length, and almost nobody uses them directly. What everybody uses is a slice, which is a view onto an underlying array: a pointer, a length and a capacity. That structure explains the behaviours people find surprising. Passing a slice to a function copies the three-field header, not the data — so the function can modify the elements and the caller sees it. Slicing a slice produces another view onto the same array, so writing through one is visible through the other. The one that bites hardest involves append. If the slice has spare capacity, append writes into the existing array and returns a slice pointing at it. If it does not, a new larger array is allocated and copied. Which of those happened determines whether an earlier slice sees the change, and the answer depends on capacity rather than on anything visible in the code. The defensive habit is to copy explicitly when you need independence, and to be careful about holding a small slice of a very large array — the whole backing array stays alive as long as any slice references it, which is a memory leak that looks like nothing at all. defer, and the cleanup that cannot be forgotten defer schedules a call to run when the surrounding function returns, whatever path it takes. It is how Go handles the problem that other languages solve with destructors or finally blocks, and it has one strong advantage: the cleanup sits immediately next to the acquisition, so a reader sees both in one glance and cannot lose track of an exit path. Two details matter. Arguments to a deferred call are evaluated immediately, not at the point it runs, which catches people who defer a call that reads a value expected to change. And deferred calls run last-in-first-out, which is usually what you want for nested resources. The trap is deferring inside a loop. Deferred calls run at function exit, not at the end of each iteration, so a loop opening a thousand files accumulates a thousand open handles. The fix is to put the body in its own function, which is a reasonable thing to do anyway. Context, and how work gets cancelled Almost every Go service deals with context.Context, and it is worth understanding rather than treating as a parameter to pass along. A context carries a cancellation signal, a deadline, and request-scoped values. When a client disconnects or a timeout fires, the context is cancelled, and every function holding it can stop — abandoning the database query, closing the connection, releasing whatever it held. Without this, work continues after nobody is waiting for the answer. On a busy service that is a meaningful share of all the work being done. The conventions are strict and worth following: the context is the first parameter, it is never stored in a struct, it is never nil, and the values it carries are for request-scoped metadata such as a trace identifier — not for passing optional arguments, which is a tempting shortcut that makes the function signature a lie. What to practise
- Write a program with a goroutine leak — start a goroutine blocked on a channel nobody writes to — then find it. Understanding how goroutines end is more important than starting them.
- Define an interface in the package that consumes it, not the one that implements it, and notice how testing changes.
- Handle every error for a whole program without discarding one. Then look at how much of the logic is failure handling, which is honest information about your problem.
- Run the race detector on concurrent code you believe is correct.
- Use
selectwith a timeout instead of a blocking read, which is the pattern most real services need. Go's lasting lesson is that leaving things out is a design activity, not an absence of one. Most languages accumulate; Go declined to, and the result is a language that is unusually easy to read a year later — which, for software maintained by teams, is the property that actually compounds.