DevelopmentAll levels

Go

Go is a language designed to be boring on purpose: few features, one obvious way to do most things, and a compiler that produces a single executable with nothing else to install. What it does exceptionally well is concurrency — you write plain sequential code, start as many goroutines as the problem has parts, and the runtime handles the rest. This page covers the scheduler, channels, errors as values, the deliberate gaps, and where Go teams get into trouble.

Artefact
One static binary
Concurrency
Goroutines, a few KB each
Errors
Values, not exceptions

Why blocking is free here

A goroutine is not a thread. It starts with a stack of a couple of kilobytes that grows as needed, so creating a hundred thousand of them is ordinary rather than reckless. The Go runtime multiplexes them onto a small number of real operating-system threads — as many as you have cores, by default — and schedules them itself.

A goroutine costs a few kilobytes, so Go programs create one per request or per connection rather than pooling them. The runtime multiplexes them onto a small number of OS threads: each logical processor P owns a run queue and runs on one thread M, and GOMAXPROCS decides how many run at once. When a goroutine makes a blocking call, the runtime detaches the thread and hands the queue to another one, so the rest keep running — which is why straightforward blocking code performs well in Go and badly in a single-threaded runtime.

The consequence is the thing worth carrying away. When a goroutine makes a blocking call — a database query, an HTTP request, a file read — the runtime parks it and hands its run queue to another thread, so nothing stalls. You get the scalability of an asynchronous server while writing code that reads top to bottom, with no callbacks, no promise chains, no coloured functions and stack traces that mean what they say. Node solved the same problem by making everything asynchronous; Java arrived at Go's answer two decades later with virtual threads.

Goroutines talk through channels: a typed pipe where a send blocks until a receiver is ready, and vice versa. That blocking is the synchronisation — passing a value through a channel transfers responsibility for it, which is what the community slogan "share memory by communicating" means in practice. select waits on several channels at once, and combined with context it is how a request cancels the work it started when the client goes away.

Channels are not the answer to everything, and new Go developers overuse them. When several goroutines simply need to read and write one shared structure, a sync.Mutex is clearer, faster and easier to reason about than a channel-based protocol. Use channels to hand work over and to signal; use a mutex to protect state. And run the tests with -race, because Go will happily let two goroutines write the same map and the race detector is the only thing that will tell you.

Errors are values

Go has no exceptions. A function that can fail returns an error alongside its result, and the caller checks it. This is the design decision people argue about most, and the argument misses what it buys: every place a failure can occur is visible in the code, at the line where it happens, and there is no invisible path by which control leaves your function. You cannot forget to consider an error, because the compiler will not let you ignore the variable you declared to hold it.

The cost is repetition — the three-line check that appears everywhere. Modern Go softens it: wrapping an error with fmt.Errorf and %w builds a chain that carries context up without losing the original, and errors.Is and errors.As inspect that chain at the top, where the decision belongs. The pattern to aim for is that low-level code adds context and returns, and exactly one layer near the boundary decides what to do — log it, retry it, or turn it into a status code.

Deliberately small

Go leaves out features other languages consider essential, and the omissions are the design rather than an oversight. There is no inheritance — types embed other types and satisfy interfaces implicitly, without declaring that they do. There are no constructors, no operator overloading, no default arguments, and until recently no generics. The point of all this is that Go code written by a stranger looks like Go code written by you, which is what makes a large team on a large codebase workable.

DecisionWhat you getWhat you give up
Implicit interfacesConsumers define the interface they need; packages stay decoupled.Finding every implementation of an interface takes tooling, not grep.
No inheritanceComposition by embedding; no fragile hierarchy to trace upward.Patterns built on class hierarchies must be redesigned rather than translated.
Errors as valuesEvery failure path is visible; no invisible unwinding of the stack.Repetition, and a real risk of context being dropped at each hop.
One static binaryDeploy by copying a file; container images of a few megabytes.Cross-compilation needs care once C libraries enter the picture.

Two more things shape day-to-day work. gofmt ends every formatting discussion by making the formatting non-negotiable — there is one layout and the tool applies it. And the standard library is unusually complete for a backend: an HTTP server, JSON, TLS, templates, testing and profiling are all in it, so a production service can genuinely have almost no third-party dependencies. Generics arrived in Go 1.18 and are useful for containers and utility functions; they are not an invitation to rebuild the type-level machinery of another language.

How this shows up in real delivery

The operational story is why Go took over infrastructure. The build produces one statically linked executable with no runtime to install, which means a container image measured in megabytes, a start-up measured in milliseconds, and a deployment that is a file copy. Docker, Kubernetes, Terraform and Prometheus are all written in Go, and that is not a coincidence — those are exactly the programs where a small, fast, dependency-free binary is the requirement.

Go's garbage collector is tuned for latency rather than throughput: pauses are typically well under a millisecond, and it achieves that by doing more work concurrently and using more CPU than a throughput-focused collector would. For a service, that is the right trade. The tooling to see what your program is actually doing ships with it — pprof for CPU, memory and goroutine profiles, and the race detector under go test -race, which should be part of CI rather than something you remember to run.

Where it degrades

  • Goroutines started without a way to stop them, which leak for the life of the process and grow with traffic.
  • Errors returned unwrapped, so a failure ten layers down arrives as "connection refused" with no context.
  • panic and recover used as an exception mechanism for ordinary, expected failures.
  • Channels used where a mutex was the simpler tool, producing a protocol nobody can follow.
  • Shared maps written from several goroutines without a lock, which the race detector would have caught.
  • Layered abstractions imported from Java or C#, which fight the language rather than using it.
  • context accepted as a parameter and then ignored, so cancellation never actually reaches the work.

When to use it

Use it when

  • Network services and APIs with high concurrency, which is the workload the runtime was designed around.
  • Infrastructure and CLI tools, where a single dependency-free binary is worth more than any language feature.
  • Containers and serverless functions that need to start in milliseconds and stay small.
  • Teams that value one readable style over expressiveness — the language enforces it for you.

Avoid it when

  • Domains with deep type-driven modelling, where the deliberately small type system will feel like a cage.
  • Data science and machine learning, where the libraries simply are not there and Python is.
  • Rapid CRUD product work needing an admin, an ORM and auth on day one — Go gives you none of that.
  • Hard real-time constraints, where any garbage-collection pause at all is unacceptable.

Found this useful?

Share it with someone who is working on the same problem.