An Honest Review of Go
Published 2025-08-19 18:53:00 -05NOTE:
I have written a few small projects in Go, so don’t assume what I am writing is expert opinion on Go. These are just my first thoughts from using the language.
For the past few months, I have been writing Go. I am now considering returning to Rust, but I first want to write what I do and don’t like about Go.
Things I like
Concurrency
Unlike most other languages, concurrency is not an
afterthought in Go. Channels and Goroutines are built right
into the language as first class features and are mostly a
joy to work with in my experience. Go manages to avoid the
famous problem of colored
functions that plague a lot of other languages’
concurrency models. Additionally, Channels and
select
statements are in general really nice to
use. It’s really hard to get concurrency right and the fact
that Go has mostly Gotten concurrency right is very
impressive.
Type System
Go’s type system is intentionally very simple and does not allow for complex inheritance trees. While Go does have struct embedding:
// all methods of Animal are now implemented on Dog
type Dog struct {
Animal }
It is different from single inheritance because you can embed multiple structs and struct embedding is fundamentally syntactic sugar. Instead of writing this:
type Animal struct { ... }
func (a Animal) DoSomething() { ... }
type Dog struct { Animal Animal }
func main() {
{}.Animal.DoSomething()
Dog}
You write this:
type Animal struct { ... }
func (a Animal) DoSomething() { ... }
type Dog struct { Animal }
func main() {
{}.DoSomething()
Dog}
While Dog
can override
DoSomething
:
func (d Dog) DoSomething() { ... }
func main(
{}.DoSomething()
Dog)
The original implementation still exists:
// both work
{}.DoSomething()
Dog{}.Animal.DoSomething() Dog
NOTE:
Struct embedding also includes fields not just methods
Additionally, in Go a struct does not have to explicitly
fulfill an interface for it to apply. This is different from
most other languages, where interfaces must be implemented
explicitly for them to apply. This means that the empty
interface interface{}
or any
can
be used to effectively introduce dynamic typing, where types
can be switched on with for example a type switch statement
at runtime. This makes things like Printf
and
html and text templates way simpler to understand, and, most
importantly, possible to write without using macros like C
and Rust.
Syntax
WARNING:
While the rest of this post can at least pretend to be objective, this is just raw unfiltered opinion.
I like Go’s condensed syntax when it comes to ergonomics. It’s way simpler to write type annotations without the colon or other characters and it saves typing time.
I also like using uppercase and lowercase letters for visibility:
// only accessible to the package
type hello struct{ ... }
// public
type World interface { ... }
It just makes sense.
Also, I hate having to write
pub
all the time in Rust.
Things I Don’t Like
Enums1
NOTE:
Yes, I know that this is a horse that has been already beaten to death, but it’s really annoying so I have to contribute my own beating to make sure this horse is really dead.
One of my least favorite things in Go is the lack of any enum type. Sometimes you just want to have an enum and Go makes it a pain to do. The most commonly accepted “solution” to this problem is to just make a bunch of constants which are part of one type:
type State int
const (
= iota
Off State
On
Error)
This “solution” is to enums what a piece of tape is to a
collapsed bridge. Not only is this syntax annoying and
decoupled, it doesn’t even accomplish it’s primary Goal.
There is no guarantee that every value of State
will be either On
, Off
, or
Error
. So unless you exhaustively check every
value of State
in every switch statement in
every function in your program, you have no guarantee of
anything. A consumer of your program could just pass in a
value of State(500)
and it’s anyone’s guess how
the program will mess up. Any sane language would have some
sort of syntactic sugar for constant groups to ensure that
it’s a closed set but Go just decides to leave that work to
the programmer.
To make things worse, Go has no idea that that you want exhaustiveness in switch statements either.
// this code compiles without warnings!
var st State
switch st {
case On: ...
case Off: ...
}
While you could just trust the consumer of your API not
to do something stupid like pass in State(500)
and maybe use linters to ensure exhaustivity, this is an
astonishingly bad solution forced upon the developer by a
language that prides itself on being simple and elegant.
Immutability
Go has two types of variables: constants and mutable variables, declared like so:
const a = 45 + 77
var b = 22
Here is the problem: variables declared with
const
need to be compile time
constants. All of these examples involve assigning to a
constant a value known at compile time but none of
them will work:
type A struct{ val int }
const a = A{3}
func B() int { return 3 }
const b = B()
const hash = map[string]int {
"HELLO": 1,
"WORLD": 2,
}
What is the alternative? Use var
of
course:
var a = A{3}
var b = b()
var hash = map[string]int {
"HELLO": 1,
"WORLD": 2,
}
This is an absolutely terrible solution especially if your API will be used by people and these are symbols exported by your package. Anyone can change these variables and break your package. The “solution” to this problem is to use a function:
var _data = map[string]int { ... }
func Data() map[string]int { return _data }
This works but it sucks.
Errors
In Go, idiomatic error management involves using the
error
type. For example, a safe division
function could be implemented as so:
func safe_divide(a,b int) (float, error) {
if b == 0 {
return 0.0, fmt.Errorf("divide by zero")
}
return a/b, nil
}
This value can be consumed as follows:
, err := safe_divide(4,2)
resif err != nil {
.Fatal(err)
log}
(res) doSomethingWith
It’s become something of a meme to bemoan the supposed
difficulty of writing if err != nil
. I actually
won’t make that point because I think it is a very surface
level point that has been talked about to death and I don’t
think the ‘verbosity’ of this code snippet is such an
issue.
I have two principal problems with this approach, one of
them about the “tuple” being used as a return type and
another about the error
type itself.
Tuples don’t exist
This type, (T, err)
is not actually a real
type. Go’s type system does not include tuples. This
expression is something that can only be handled by
immediate destructuring.
For example, this (albeit contrived) snippet won’t work:
func doIfErrNil[T](val (T, error), f func(T)) {
, err := val
vif err != nil { return }
(v)
f}
This restriction prevents chaining, which is unidiomatic anyway (and maybe for good reason) or doing anything with (T, error) values
The error
type
sucks
Here is the full definition of the type
error
:
type error interface {
() string
Error}
So if I am writing a library that runs some programs I
can create my own type that implements
error
:
type progError struct {
string
prog int
code string
reason }
func (e progError) Error() string {
return fmt.Sprintf("%q returned code %d: %q", e.prog, e.code, e.reason)
}
In Go, the idiomatic thing to do is to hide
progError
behind the return value
error
. In that case, the information we have in
the struct is left behind. The user now has an interface
value error
that the only thing they can do is
access the string representation of. How is this error
useful to the people writing the program? Sure, they can
print it out, but wouldn’t the programmer want to do
different things depending on what type of error is
returned? The only resort the consumer of this library has
is to parse the string value of this error for useful
information. This is a terrible strategy because
nothing is stopping the library authors from changing the
string representation, something which is totally at their
discretion in my opinion.
The worst part is that this is not even a hypothetical.
It in fact happened to me when writing code that used
os.Stat
to get file information2:
func rootInfo(root, path string) (has bool, isDir bool, err error) {
= pathpkg.Clean(path)
path , err := os.Stat(root + "/" + path)
infoif errors.Is(err, os.ErrNotExist) {
return false, false, nil
}
if err != nil && strings.HasSuffix(err.Error(), "not a directory") {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Manually checking if the error is a certain type of error
return false, false, nil
}
if err != nil {
return false, false, err
}
return true, info.IsDir(), nil
}
It’s true that this code sucks and that there was probably a better alternative. It’s probably even true that the “not a directory” message is almost 100% not going to change in the future. However, it’s still a problem and a problem that stems from one thing:
In Go, errors are values. They just aren’t particularly useful values.
Contrast this to how Rust handles errors.
In Rust, errors are also values but they are
useful values. If I do an I/O operation in Rust, I
am likely to get a std::io::Result<T>
,
which is just a
Result<T, std::io::Error>
. This
std::io::Error
’s type can be easily accessed
with the kind()
method of
std::io::error
which lets you easily understand
what kind of error you are dealing with.
Why does Rust have more useful errors than Go?
- Rust has enums and sum types
- Rust doesn’t have the pervasive idiom of hiding your error behind an interface that reveals little about the error
EXTRA:
For more, check out the std::io
documentation
By enum, I mean a closed set of constants given a name as a type. Enums are often confused with sum types, which is a closed set of types given a name. However one could argue this distinction doesn’t matter because I think the absence of both harms Go as a language.↩︎
You can see this code at benraz123/server (archive), specifically at this commit↩︎
Comments (Disqus)