An Honest Review of Go

Published 2025-08-19 18:53:00 -05

NOTE:

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() {
    Dog{}.Animal.DoSomething()
}

You write this:

type Animal struct { ... }

func (a Animal) DoSomething() { ... }

type Dog struct { Animal }

func main() {
    Dog{}.DoSomething()
}

While Dog can override DoSomething:

func (d Dog) DoSomething() { ... }

func main( 
    Dog{}.DoSomething() 
)

The original implementation still exists:

// both work
Dog{}.DoSomething()
Dog{}.Animal.DoSomething() 

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 (
    Off State = iota
    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:

res, err := safe_divide(4,2)
if err != nil {
    log.Fatal(err)
}
doSomethingWith(res)

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)) {
    v, err := val
    if err != nil { return }
    f(v)
}

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 {
    Error() string
}

So if I am writing a library that runs some programs I can create my own type that implements error:

type progError struct {
    prog string
    code int
    reason string
}

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) {
    path = pathpkg.Clean(path)
    info, err := os.Stat(root + "/" + path)
    if 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?

EXTRA:

For more, check out the std::io documentation


  1. 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.↩︎

  2. You can see this code at benraz123/server (archive), specifically at this commit↩︎


Comments (Disqus)

Comments (Facebook)