#Why I think Go is a Terrible Language
Table of Contents
I’ll start with a disclaimer that I know will bother some of you. Maybe bother you a lot, or maybe make you laugh: this is not a screed from someone who doesn’t understand Go. You wish it were, and frankly, I wish it was. Unfortunately for me, I have written Go in the past and I have also shipped Go to “prod” in the past. In fact, I continue to write Go when I’m too lazy or tired to care about correctness—for reasons we’ll talk about shortly. It is also worth stating out right that while I am still in the process of converting to more… sane languages, some of my most critical infrastructure components have been Go for as long as I can remember. They are still Go, and they still hold.
This post has started as a Hedgedoc document I uploaded in response to questions asking me why I think Go is designed by people who understand language design very well, but refuse to follow established and beloved conventions. I am still behind this statement and throughout this post you will see exactly why I believe this. I have read much of the spec and the proposals. I have read, or at least tried to read, the team’s stated reasoning and surrounding memos. The blog posts about why the things are the way they are and the such. Their defenses, arguments, and more.
What follows is a case. It is my case about why I think Go is a terrible* language. This is, put most simply, my attempt at a precise, specific, and deliberately uncharitable document where the language deserves what is coming. It is about why Go is a failure of a design philosophy dressed up and paraded around as pragmatism. The post itself is not to dictate whether you should use Go or not. I’ll make it very clear that I franky could not care less about what you use. The post itself is to organize my thoughts, leave a structured and developed list of my arguments for those interested in why I don’t like the language. This is also not for experts who are extremely comfortable with their language. My goal is not changing your mind, but making my case out in the open. This is also not a hate mail towards Go, but a strongly worded opinion essay. I intend to write something truly wholesome for Rust in the future, and one truly vile for Zig. For now, let’s talk about Go and its design choices.
Failure by Design
The first thing I want to talk about, and perhaps the lowest hanging fruit that
everyone reaches for, is the error handling patterns of Go. This is simply
because it really is that bad. I think the designers fully understood
exceptions, sum types, and structured error propagation. Quite sure they not
only understand those patterns, but also see the individual beauty of it. They
knew about Haskell’s Either, about ML’s type-safe exceptions and Java’s
checked exceptions which are flawed indeed but at least attempt correctness.
Instead, Go enforces explicit error returns everywhere, even when the result is
repetitive boilerplate that actively obscures control flow. Here, let me give
you a better idea. A typical Go function doing three fallible operations would
look something like this:
func process(path string) error { f, err := os.Open(path) if err != nil { return err }
data, err := io.ReadAll(f) if err != nil { return err }
result, err := parse(data) if err != nil { return err }
return store(result)}The actual logic (open, read, parse, and store) is four lines. FOUR. The error
plumbing, however, is twelve. The signal-to-noise ratio is, if my math is
correct, 3:1, and this is called explicit. Now compare it to Rust, where ?
propagates errors with identical semantics but doesn’t consume your screen free
estate.
fn process(path: &str) -> Result<(), Error> { let data = fs::read_to_string(path)?; let result = parse(&data)?; store(result)}How many lines is that? 1, 2, 3… Oh yeah, FOUR.
The crucial difference isn’t syntax sugar my dear reader. In Rust,
Result<T, E> is a real type. E is a real type parameter. You can write
generic combinators over it, map_err, and_then, unwrap_or_else. The error
type is part of the function’s contract in the type system, not a convention you
hold in your head.
In Go, error is a single-method interface. Any type with Error() string
satisfies it, which means there is no structured hierarchy, no enforcement and
absolutely no static knowledge of what errors a function can actually produce.
Somewhere around Go 1.12 or 1.13, the Go team added errors.Is and errors.As
as a post-hoc attempt to recover structure from this mess. This is simply an
admission of guilt. They rejected typed errors, and then recreated a weaker,
ad-hoc version of the same concept without proper exhaustiveness, without
enforced structure and without syntax support.
io.EOF is the clearest and perhaps the best illustration of where the model
collapses. It is a sentinel value. A global error variable callers check with
==. Entire packages special-case it. In a language where Error is a proper
enum, EOF would be just another variant the compiler forces you to handle but in
Go it is a convention held together by documentation and (allegedly) discipline.
It is also occasionally violated in ways that take hours to debug. The team
eventually ran a formal design process. The proposal that followed introduced
things like check/handle, the try builtin, a ? operator and a bit more.
As you’d expect, all of those were rejected. Eventually, sometime in 2025, the
Go blog has proudly declared that there will be no further syntax-level
attempts. Now your boilerplate is intentional, and permanent.
The even lower hanging fruit—so low that you don’t even have to lift your
arm— is nil, which is its own disaster. Go has nil, and Go has interfaces.
Those two interact in a way that is genuinely (and verifiably) broken. An
interface value is internally a two-word structure. That means it consists of a
type pointer, and a data pointer. A nil interface means both are nil. A
German walks into a bar, end of the joke.
Well not really, but a nil pointer to a concrete type type assigned to an
interface variable has a non-nil type pointer and a nil data pointer, so the
interface is no nil. Confused? Here, let me illustrate. This produces
something like:
type MyError struct{}func (e *MyError) Error() string { return "oops" }
func getError() error { var err *MyError = nil return err}
func main() { if err := getError(); err != nil { fmt.Println("non-nil error") // this prints }}The function returned no error. The caller receives a non-nil error. Mind you,
this is not even some obscure corner case. It regularly shows up in real,
“production” codebases and the idiomatic fix is someone snarkily telling you
“remember not to do this” enough times so that it never leaves your head how
much you want to punch that person. The actual fix, on the other hand, is
Option<T>, which represents absence in the type system rather than relying on
a zero value that just happens to carry type metadata. Rust’s Option<T>
makes it impossible to use a value that might be absent without explicitly
handling both cases. There is no typed None that looks like Some. The compiler
catches it before your users do.
You’d expect nil to be at least coherent given how dominant it is, but no.
nil is not even one coherent thing. You can call methods on nil pointers if
the method doesn’t reference the receiver. A nil slice has len 0, and is
safe to range over. A nil map panics on write but not on read. A nil
channel blocks forever on receive or send. A closed channel panics on send.
That was a lot of nils in one sentence. Phew. Anywho, the point is that those
are not rules derived from a single principle, but a collection of special cases
to memorize, and getting any one of them wrong produces a runtime panic with no
compile-time indication that anything was wrong. And I think the type system’s
failures run much deeper than just nil. Go has no sum types, no exhaustive
matching, no non-nullable types, no const generics, no immutability by
default. There are basic tools for making illegal states unrepresentable, and
the designers knew this. They then explicitly rejected them in favor of runtime
discipline. As much as I like Discipline, that is no substitute for proper
language design.
The absence of sum types is the most damaging. In Rust you typically write:
enum ParseResult { Integer(i64), Float(f64), Text(String), Unknown,}The compiler forces you to handle every variant. Add a new one later, and every
match expression that doesn’t cover it becomes a compile error. In Go you
write an interface, use a type switch, and every unhandled case (silently) falls
through unless you manually add a default that panics. This is a convention,
not a guarantee. Nothing in the Go language actually stops you from being unkind
to your future self. Nothing stops you from adding a new implementing type and
never updating the thirty switch statements scattered across your codebase. You,
if ever, find out at runtime. Rust, Haskell, Swift, TypeScript with
discriminated unions—they all surface this as a compile error. Go surfaces it
as a silent no-op, or a wrong result propagating for hours before anyone
notices.
On the same cursed note, non-nullable types don’t exist. Every pointer,
interface, slice, map, channel or function can and will be nil. You cannot
declare a variable that the compiler guarantees is never nil.
In Kotlin, String is non-nullable and String? is nullable. This is enforced
at every callsite. In rust, you use Option<T> and the match handles the absent
case. In go, however, the best you can do is put a comment saying “THIS
PARAMETER MUST NOT BE nil” and hope that someone does not pass it within the
next 3 months, leading the runtime panic. Similarly, immutability is just as
absent: there is no const for struct fields, no readonly, no way to declare
a value passed to a function must not be mutated. Go has const for primitive
literals and that’s it. Everything else is just… mutable. The alternative to
immutability guarantees is “just don’t mutate things!” is once again convention,
or advice. There is no guarantee, and for a language catering to beginners this
is too heavy of a footgun to hand loaded to the users.
The type system’s gaps extend into territory the standard critique rarely
reaches. encoding/json unmarshals all numbers into float64 when the target
is interface{}, silently losing precision on integers exceeding the 53-bit
IEEE 754 mantissa. The integer 9007199254740993 becomes
9.007199254740992e+15. The fix is Decoder.UseNumber(), but the default path
discards data with no warning. A nil slice marshals to null while an empty
slice marshals to []; a nil map marshals to null while an empty map
marshals to {}. The distinction between “this field is absent” and “this field
is present but empty” is semantically meaningful in many APIs, yet the language
provides no way to declare which meaning a given value carries. The
differentiation depends on a runtime property indistinguishable from emptiness
in every other context.
panic(nil) is indistinguishable from no panic at all. recover() returns
nil when the panic value was nil, so a deferred recovery cannot tell whether
it caught a panic(nil) or nothing happened. recover() itself only works when
called directly inside a deferred function—wrapping it in a helper silently
breaks it, with no compiler warning and no lint to catch the error. Comparing
two interface{} values with == panics at runtime if the underlying types are
incomparable, such as maps or slices, because the type information is erased at
the comparison site. The compiler cannot protect you because the concrete type
is gone.
Until Go 1.22 in 2024, loop variables were reused across iterations:
for _, item := range items { go func() { process(item) // every goroutine sees the last item }()}This was a known problem from the Go 1.0 proposals and fixed only after twelve years. The language team initially defended the behavior as consistent with the spec. The spec was wrong. Twelve years of production incidents before a language-level fix is institutional reluctance to acknowledge a design decision—reusing a loop variable for efficiency—produced a persistent, well-understood bug surface.
Let’s Talk Generics
The generics situation deserves its own section, and its own extended
treatment because it is such a perfect illustration of the team’s priorities.
Go 1.0 shipped sometime in 2012 without generics. Which is fine. Then, for
almost a decade, the team explicitly argued that code duplication is
PERFECTLY FINE and was preferable to abstraction, actually. The result was
interface{} abuse everywhere, reflection-based helpers, and the unfortunate
go generate pipelines that turned code generation into a first-class workflow.
The library itself either duplicated implementations for each concrete type, or
punted to interface{} and runtime assertions. If you were a user, you were
told to copy-paste sort functions.
This is supposed to be pragmatic, in case you haven’t noticed.
I’m definitely not the first person to bash Go over generics, but I still want
to give you its history proper. Generics finally arrived in Go 1.18 sometime in
2022, and they were deliberately constrained. You cannot define a method on a
non-generic type that introduces its own parameters, which rules out a wide
class of useful designs. You cannot write, for example,
func (r *Repository) FindAll[T Entity]() ([]T, error).
Your workaround must be either making the entire struct generic, which may be structurally wrong, or moving to a free function, which discards the method set. Neither is satisfactory, and Rust has no such restriction. Type inference is local and frequently fails on anything involving interfaces or composite types, in cases where any reasonable inference engine would determine the parameters unambiguously. You cannot access a field through a type parameter even if every type in the constraint set shares that field. You must define a method instead, because the type system cannot reason about struct layout through constraints. There is no specialization: you cannot provide a more efficient implementation for a specific concrete type. Go 1.25 removed the core type restriction from type sets, a genuine improvement, but the rest of these limitations remain.
Structural typing compounds the type system’s weaknesses in its own particular way. Any type with the right method set automatically satisfies an interface, with no declaration of intent. When a type accidentally satisfies an interface, it becomes part of an implicit contract its author never intended to make. When you change a method signature, you may silently break callers who depended on that accidental relationship, with no error at the definition site; only at the use site, possibly in a different package. The canonical workaround is:
var _ io.Writer = (*MyWriter)(nil)A compile-time assertion that produces a type error if *MyWriter doesn’t
implement io.Writer.
Think about what that means: the language leaves intent so implicit that
developers invented a hack: assign a nil pointer to a blank identifier with an
explicit type. This is just to recover the ability to state intent. That this
hack is idiomatic is more damning than any external criticism. Rust uses
explicit impl Trait for Type declarations. The intent is in the code. You
cannot accidentally implement a trait. If you change a trait’s definition, the
compiler tells you exactly which impl blocks need updating. The contract is in
the source, not inferred from the method set.
Now, a common counterargument is the consumer-side interface pattern: you define
a narrow interface at the call site with only the methods you need, the compiler
verifies the caller provides something that fits, and changing the interface
surfaces every call site that needs updating. On its own terms this is
reasonable. For protocol handlers and I/O pipelines—the domains Go was
designed for—accepting “anything that has a Read method” is genuinely
cleaner than requiring every type to declare its lineage. The problem is that
this pattern coexists with producer-side interfaces defined once and consumed
everywhere: json.Marshaler, fmt.Stringer, database/sql.Scanner,
encoding.TextMarshaler. These carry behavioral contracts that are not encoded
in the type system. If you add a MarshalJSON() ([]byte, error) method to a
struct for internal logging purposes, json.Marshal will call it instead of
using reflection. Your JSON output changes silently—not a compile error, not a
test failure unless you happen to have coverage on that exact path. The encoding
packages are built around structural detection: json.Marshal checks for
Marshaler, then TextMarshaler, then falls through to reflection.
fmt.Sprintf checks for Stringer. database/sql checks for Scanner. Add
the right method name, and the behaviour of your program changes at a distance
without your knowledge or consent.
This problem compounds over time through interface pollution. Because types
satisfy interfaces implicitly, a type that grows methods through maintenance may
begin satisfying interfaces it never previously matched. A data transfer object
that acquires a Write([]byte) (int, error) method because someone embedded a
buffer for convenience is now an io.Writer. Code that accepted an io.Writer
will accept this DTO, and the mismatch between “this is a writable buffer” and
“this is a data object that happens to have a Write method” becomes a design
problem the type system cannot surface.
But The Concurrency
Go’s concurrency model is one of its marketed features and perhaps the first to be mentioned in response when you criticize Go. It is also where the gap between appearance and reality is widest.
Goroutines are genuinely cheap.
The “share memory by communicating” slogan gestures at CSP and the channels
may look principled, however, as if to really rub salt in the wound, the
language provides none of the static guarantees that would make any of it
actually robust. Data races are runtime errors at best: the race detector is
opt-in via go test -race, disabled in production by default, and only catches
races that occur in the specific test run you happen to execute.
Not to mention, Go has no ownership model. Any goroutine can read or write any
shared variable at any time. The type system has no concept of thread safety
whatsoever. Rust’s type system makes data races impossible by construction:
Send and Sync are automatically derived marker traits, Mutex<T> requires
you to acquire the lock before you can touch T, Arc<T> requires T: Send
before it compiles. Rust doesn’t make data races hard to write, it simply makes
them impossible to compile.
Channels in Go do not enforce ownership or linearity. After you send a value on
a channel, you can still use it. Nothing in the language prevents this. You
cannot encode “send exactly once, then close” either and you cannot ensure a
channel is closed by exactly one goroutine. Goroutines have no parent and no
lifecycle tied to any scope. A function can return while the goroutines it
spawned are still running, still holding references to state that should have
been released, possibly blocked forever on a channel that will never receive.
Goroutine leaks are trivially easy to produce. The standard mitigation is
context.Context, but passing it is optional, checking the cancellation signal
is optional, and nothing enforces either. Kotlin coroutines have lexically
scoped lifetimes built into the runtime. Go has documentation asking you to be
careful.
sync.Mutex is not parameterized over the data it protects. You lock it and
then you can access anything. The relationship between a mutex and the state it
guards exists in comments. Rust’s Mutex<T> wraps the data it protects: the
only way to access T is through the guard you get by locking it. The invariant
is in the type. In Go it is in the README.
defer is useful and also function-scoped rather than block-scoped, which makes
it wrong for a significant class of resource management problems. Every deferred
call runs when the enclosing function returns, not when the enclosing block
exits. This is fundamentally different from RAII. In C++ and Rust, a destructor
runs when an object goes out of scope—the end of an if body, a loop
iteration, an arbitrary block. In Go:
func processItems(items []Item) error { for _, item := range items { f, err := os.Open(item.Path) if err != nil { return err } defer f.Close() process(f) } return nil}Every file opened in the loop stays open until processItems returns. The fix
is extracting the loop body into an immediately-invoked anonymous function, a
workaround that exists because the primitive isn’t expressive enough for what
you’re doing. Rust’s Drop triggers at block exit without any special syntax.
The resource is released when the owning variable goes out of scope and that’s
the end of it.
Less discussed are the channel semantics themselves, which form a matrix of runtime behaviors with no unifying principle. A send on a nil channel blocks forever. A receive from a nil channel blocks forever. Closing a nil channel panics. Sending on a closed channel panics. Receiving from a closed channel returns the zero value immediately. Closing a closed channel panics. Six distinct runtime behaviors for three states of a single construct, every one of them a runtime event with no compile-time guard. The language that prides itself on simplicity forces you to memorize this table.
time.After is another trapdoor. Used in a select loop it allocates a new
timer on every iteration, and the timer is not garbage collected until it fires.
Under load, the number of pending timers grows without bound. The fix is
time.NewTimer and explicit Stop(), but the library offers the footgun as the
shorter path and hopes the developer reads the caveat in the documentation.
Likewise, sync.WaitGroup is a raw counter with Add and Done and no static
enforcement that the two balance. Call Done one extra time across a goroutine
boundary and the program panics with sync: negative WaitGroup counter, a
message that tells you the counter went negative but not which goroutine drove
it there. Rust’s WaitGroup does not exist because std::sync::Arc and scoped
threads make the pattern unnecessary. When you need it, Crossbeam provides one
with static drop guards. Go gives you a volatile integer.
net/http ships with ServeHTTP(ResponseWriter, *Request) returning nothing.
Every error path inside every handler must be handled inline instead of
propagated to middleware that converts errors to responses. The community has
reinvented
type HandlerWithError func(http.ResponseWriter, *http.Request) error in
roughly every Go web framework ever written. The standard library chose a
signature that every non-trivial user must wrap or replace to achieve basic
error composition.
At Least It’s Simple
I think the first and most trivial element of confusion will come from how Go
programs will be structured. I find it to be the exact opposite, however, as
Go has little to no coherent “packaging” conventions. This is to say,
package-level variables and init() functions add another class of problems.
You see, initialization order within a single file follows declaration order.
Across multiple files in the same package it is not guaranteed. The compiler
tries to resolve the dependency graph but the spec’s response to cross-file
ordering subtleties is essentially “don’t rely on it.” init() functions run
automatically, cannot return errors, cannot accept arguments, cannot be called
or tested directly, and can have arbitrary side effects.
To illustrate this issue, I’d like to give you the database/sql driver as an
example. You writeimport _ "github.com/lib/pq" and the blank import registers
a database driver as a side effect inside an init() call. There is no explicit
initialization, no error return at the callsite, no indication from the import
alone that anything has happened. If registration fails you panic. If you forget
the import you get a runtime error on the first database operation. A language
that claims to value explicitness ships this as the idiomatic database driver
pattern.
This is the first crack in the “simple” story: the language removes visible machinery, then smuggles the machinery back in as package-level side effects. The code looks smaller because the initialization path is no longer in the code you are reading. That is not simplicity. That is hiding the causal chain.
On a similar note, the module system’s history is also an embarrassment that the
current state only partially redeems. While a beginners might not be
interested in “hacking” the module system, they might as well be affected by its
side-effects! Go shipped without dependency management. GOPATH had no
versioning;go get fetched the latest commit with no lockfile and no way to
specify which version you needed. The community produced Godep, Glide, dep, and
govendor in succession, each incompatible and each dying in turn. Go modules,
required in 1.16, fixed the core problems but introduced new ones. The major
version suffix convention encodes version into the import path itself, so
github.com/foo/bar/v2 is a different import path from github.com/foo/bar.
Updating a direct dependency to a new major version means changing every import
statement in your codebase that references it. No other major language ecosystem
conflates import paths with version identity this way. The replace directive
works for local development but does not propagate to consumers, so the
development workflow diverges from the published module and requires manual
management across multiple related modules.
This is not some unrelated ecosystem complaint stapled onto a language rant. It is the same design instinct again: avoid richer structure in the language and tooling, then push the resulting complexity into conventions, import paths, side effects, and developer memory. The beginner may not care about module mechanics on day one, but the project will care eventually, and then the bill arrives with interest.
Tooling
Of course, there’s also tools not made by the community. Or, in other words, let’s talk about official tooling.
As anyone who has ever used Go will tell you, and as you might have noticed
throughout the post, Go has a vast first-party tooling. To its credit, I think
the tooling is good in a way that doesn’t get enough attention. gofmt is
excellent, go test being built in is excellent. go vet caches a subset of
common mistakes, but the typed-nil-as-interface bug described above is not
caught by go vet. Goroutine leak detection is not built in. Correct mutex
usage enforcement is not built in. Those require staticcheck, which is
excellent but is third-party and requires explicit CI integration. The
baseline static analysis for a Go project is significantly weaker than the
language’s correctness story requires. Build constraints until Go 1.17 were
written as magic comments: // +build linux amd64 and not as syntax, not as a
first-class feature, as comments the toolchain parses by convention. I am rather
biased here, but this is not what good and reliable design looks like. I draw
the line at doc comments.
This matters because tooling is the usual escape hatch offered in Go’s defense.
The language does not encode the invariant, but the tool will catch it. Except
the first-party tools do not catch enough of the failures the language makes
easy. They format the code. They run the tests. They catch some obvious
mistakes. They do not turn Go into a language with non-nullable types,
exhaustive matching, scoped goroutine lifetimes, typed mutex guards, or
structured error variants. The missing guarantees remain missing. The deeper
tooling problem is that reflect and unsafe.Pointer paper over type system
gaps throughout the ecosystem. The standard library’s encoding packages use
reflection to serialize arbitrary types at runtime, which means structural
mismatches are runtime errors. Rust’s serde (which is not in stdlib) does the
same work through procedural macros with full type safety at compile time. The
comparison is straightforward and unflattering.
At this point the boundary between “tooling problem” and “language problem” stops being meaningful. Reflection, escape analysis output, race detection, and profiling are not external aids; they are compensatory mechanisms for properties the language does not or cannot express. Once you rely on them, you are no longer reasoning about your program purely through its types or its syntax. You are reasoning about compiler behavior, runtime behavior, and tooling diagnostics as part of the language. Those are not good cornerstones for a language aiming to be simple and powerful.
The memory model was tightened in Go 1.19 and is now more precisely specified
than it was. Without an ownership model this doesn’t change the practical
situation much though, violations are catchable by the race detector if they
happen to occur during a test run, and production is where you find out
otherwise. Whether a value escapes to the heap is determined by the compiler’s
escape analysis, which is a heuristic, not a contract. You cannot declare that a
value must stay on the stack or specify allocation strategy for a type. For most
programs this is an acceptable tradeoff. For latency-sensitive systems it is
not, and the debugging workflow is reading escape analysis output and profiling.
There is no language-level handle on it. Go’s GC has improved dramatically and
achieves sub-millisecond pause times in common workloads, but stop-the-world
pauses still occur, and under heavy allocation pressure or with large heaps the
pause behavior becomes less predictable. For trading systems, real-time control,
audio pipelines, or anything with hard latency requirements, a GC is
disqualifying regardless of how good it is, because “very short pause” and “no
pause” are not the same thing. Rust has no GC. Stack allocation is the default.
Heap allocation is explicit through Box<T>, Arc<T>, Vec<T>. You know where
every allocation is because, well, you put it there.
This is, if anything, where the section should land: Go is simple only if “simple” means fewer visible concepts on the surface. Once the program has to explain initialization, dependency identity, static analysis, reflection, unsafe escape hatches, allocation behavior, and latency, the complexity has not disappeared. It has merely moved out of the type system and into the runtime, the toolchain, the build graph, and the operational culture around the codebase.
Closing Thoughts
None of these problems are isolated. That is the actual point. A production Go
service at scale is functions returning (value, error) where error is an
opaque interface with several undocumented concrete implementations, callers
checking errors.Is against known sentinels and falling through silently on
unanticipated variants, goroutines outliving the requests that spawned them and
holding references to state that should have been released, a mutex somewhere
protecting the wrong data because the association was in a comment that was
accurate when written and hasn’t been updated since the refactor, a nil
interface that isn’t nil propagating up three layers before producing a panic
the stack trace makes difficult to attribute.
The same pattern repeats in the supposedly simple parts of the language: package
initialization hidden in init(), blank imports used for side-effect
registration, module identity encoded into import paths, build constraints once
parsed from comments, reflection used where the type system cannot express the
shape of the program, race detection delegated to an optional runtime tool, and
allocation behavior exposed through compiler diagnostics rather than language
guarantees. These are not separate grievances. They are the same grievance
wearing different costumes.
Every one of these failure modes is preventable in languages that encode the relevant invariants in their type systems. Every one of them requires discipline and convention to avoid in Go, and discipline and conventions fail under time pressure, team growth, and the ordinary entropy of a large codebase.
The defense of Go is that all of this is manageable. With experienced teams, with good code review, with staticcheck in CI, and with the race detector in test runs and context used consistently, Go codebases can be safe and maintainable. This is true. The question is why the language demands that entire investment of infrastructure and discipline to achieve properties that Rust, Haskell, Kotlin, and Swift provide structurally. Go was designed to solve Google’s specific operational problems: fast compilation, easy onboarding for programmers of widely varying experience, and readable code review at massive scale, just to name a few. And sure, these are real goals, held with clear intent. The original team understood exactly what they were leaving out. They made deliberate tradeoffs, consistently applied.
The problem, however, is that optimizing for easy onboarding means in this case (and many others) optimizing against correctness. A language you can learn in a week is a language that does not make wrong programs hard to write. The ceiling on what you can prove at compile time is low by design, and as codebases grow and teams turn over and systems become load-bearing infrastructure, that low ceiling costs you in ways that are slow, cumulative, and difficult to attribute directly to the language rather than to the specific code that happened to fail.
Rust is by no means a perfect language. Compile times are long enough to be a
real workflow problem on large projects. The borrow checker’s learning curve is
steep and genuinely so—not artificially steep, because the concepts it
enforces are non-trivial and require building a new mental model before the
errors start making sense. Async Rust is complex in ways that affect real
programs: 1 the story around async in trait methods, dyn and
impl Trait, and the borrow checker in async contexts is still rough in places.
The ecosystem is younger and has gaps. These are real criticisms and they should
be stated plainly. Rust is harder to learn than Go. That is true and it is not
nothing.
But Rust’s design decisions are coherent in a way Go’s are not. Sum types are embraced rather than rejected. Nil is eliminated rather than worked around. Data races are impossible rather than detectable. Mutex invariants are encoded in types rather than documented in comments. Async task lifetimes are scoped rather than floated as untracked background work. The tradeoffs Rust makes push complexity into the compiler and the learning curve, not into production incidents. Errors you make in Rust surface at compile time. Errors you make in Go surface in production. This is not a philosophical preference. The error handling is the difference between a bug your toolchain catches in two seconds and a bug that pisses you off for one long afternoon or an entire week.
Similarly, Go is not a badly implemented language either. Well, not too badly implemented. I would go so far to say that the runtime is excellent and the standard library is coherent and well-documented. The toolchain is pretty fast in ways other languages treat as aspirational. These are real achievements and I think dismissing them would be dishonest. But a language is not just its runtime. It is the guarantees it gives you, the invariants it lets you express, the class of bugs it makes structurally impossible. By that measure—the one that matters most for building software that has to be correct, not just running—Go is deliberately, knowingly, and permanently underpowered. There’s no escaping it. That was a choice, made by people who understood what they were choosing and why. It was a clear set of priorities, consistently applied, that produced a language optimized for a narrow set of operational concerns at the direct expense of correctness. The gap between what Go lets you build and what you can actually prove about what you built is not a bug in Go. It is Go.
And that gap exists for a reason. Go was designed for Google’s specific operational constraints circa 2009—2012: a monorepo with two billion lines of code, tens of thousands of engineers with widely varying experience, a build graph where every second of compilation time multiplied across the organization. Fast compilation, mechanical readability, and low abstraction were not aesthetic choices. They were operational requirements. The language satisfies them. The problem is that nearly every Go user is not Google. They do not have Google’s SRE culture absorbing the cost of runtime failures. They do not have the code review bandwidth that makes convention-based correctness feasible at scale. For them the tradeoffs Go made for Google become liabilities. The fast compilation matters less than the bugs the language fails to prevent. The onboarding speed matters less than the invariants you cannot encode. Go was designed for an organization whose scale is unique in the industry and marketed as a general-purpose language. The mismatch between the design target and the actual user base is the root cause of most of the frustrations catalogued here.
None of this means Go is useless, or that writing Go is malpractice. The runtime
is excellent. The toolchain is fast enough to be aspirational. The standard
library is coherent within the constraints of the language. Generics removed the
worst of the interface{} abuse. The race detector catches real bugs. Go
succeeds at what it was designed for: fast compilation, rapid onboarding, and
readable code at massive scale. But what it was designed for is narrower than
its user base assumes, and a language optimized for one organization’s
operational problems at the direct expense of correctness is—for most other
users—a language that makes wrong programs easy to write. The ceiling was
chosen knowingly. The question is whether you want to live under it, and whether
you think the tradeoff of knowing how to deal with a language’s quirks is worth
not picking a language with considerably less quirks.
Footnotes
-
Real Rust async has never been tried, actually. ↩