Ten years ago, I wrote Go: The Good, the Bad, and the Meh. Way back in 2013, it made it to the front page of Hacker News and got over 400 comments on /r/programming. I don’t have analytics from back then, but I suspect it’s one of my more discussed pieces of writing, and it was definitely one of my first experiences of getting a lot of feedback for my writing. (Then again, I don’t have any evidence of whether John Carmack read it, so maybe it’s not the one for my obituary.)

Anyway, it’s been a decade, and in that time I’ve gone from playing around with Go as an amateur to being a professional programmer and using Go as one of my core languages. So, I thought it would be fun to look back at what I got right, what’s changed since I wrote it, what I missed, and what I got wrong. Feel free to read or re-read the original post, or just stick to my reflections here without digging back into it. Just know that as its title suggests, I wrote it with three sections for what I thought was “good”, “bad”, and “meh” about Go at that time.

 
Note for anyone submitting this post to social media, the quotation marks in the title are load bearing. Do not remove them.


What I got right

I still agree with almost everything I listed in the “good” section from before. All of these things are still good:

  • That Go was designed for working on large projects in a team with a modern version control system
  • Using capitalization for the public/private distinction in functions, methods, variables, and fields
  • Using the directory as the fundamental unit of packaging
  • Having a single binary for deployment
  • That the go tool is fast and has built in go fmt, go doc, and go test
  • Using type last style (var x int) rather than type first style (int x)
  • Having explicit variable declaration (vs. Python’s implicit declaration by using =)
  • The Go Playground
  • Logical type names (int64, float32, etc. vs. long and double)
  • Having the three basic data types of string, variable length array, and hash map
  • Interfaces as compile time duck typing
  • Not providing inheritance

The other sections, well, we’ll come back to them.

What’s changed

The biggest change to Go the language in the last ten years has obviously been the addition of generics.

In the original post, I listed lack of generics under the “bad” section:

Idiomatic Go code has a couple of different tricks for using interfaces so that you don’t need generics… but sometimes you just have to bite the bullet and copy-and-paste a function three times so you have three differently typed versions of it. If you’re trying to make your own data structure, you can just use interface {} as a universal object type, but then you lose compile time type safety. If you want to make a universal sum function, on the other hand, there’s no really good way to do it.

Generics were added to Go in version 1.18 in February 2022. It has basically solved this complaint. A generic sum looks like this:

type Numeric interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float64 | ~float32
}

func sum[N Numeric](vals ...N) N {
    var total N = 0
    for _, val := range vals {
        total += val
    }
    return total
}

So far, generics have been a change for the better.

The other major change from the Go of 2013 is the addition of Go modules. I listed the GOPATH system of that time under the “good” section:

[If] you run go get github.com/userA/project/ it will use git to download the project from github.com and put it into the right place. Even better, if that project contains the line import "bitbucket.com/userB/library", the go tool can also download and install that library in the right place. So, in one stroke, Go has its own elegant solution to packaging and a modern, decentralized version of CPAN or PyPI: the VCS you were already using!

Go modules took this existing system and added the ability to specify version requirements for the imported packages and even Go itself. There were some bumps and hurt feelings in the transition from GOPATH to Go modules, but overall, it was very smooth and day-to-day, I can’t say that I’ve run into many problems with them. It just works, and you mostly don’t think about it.

I said at the time that I was “meh” about the fact that the Go compiler has no warnings, only errors, but the way that this has evolved over the last decade is that warnings with a low false positive rate have become go vet errors, and other warnings just end up in miscellaneous linters. So, it’s still technically true about the compiler that there are no warnings, but the Go ecosystem certainly has lots of avenues for warnings in practice, so it’s only an interesting distinction when you want to complain about Go.

What I missed

Rereading the post now, what jumps out to me is that there was no mention of unions, sum types, or optional values. I think this just goes to show how much the state of the industry has changed in the last decade. Back then, sum types and optional values were still considered academic features. You saw them in ML derived languages like Haskell, but not in mainstream C-influence languages. Swift wouldn’t come out until 2014, and Rust until 2015, but languages since then have all been judged by whether they have a solution to the billion dollar mistake.

Unfortunately, I don’t think there’s any chance that nil could be removed from Go now. Too much code has been written with it to ever really move on to optional types, at least in Go as we know it. However, there is a proposal to add sum types by restricting interface values, and I think that has a good chance of happening in one form or another.

Again, it shows how things have changed that I praised Go’s type inference as an advance in the state of the art, but now the Hacker News crowd considers Go’s type inference to be very limited compared to other languages. You either die a language nobody uses or live long enough to be one people complain about.

The other thing that stands out as an omission in the original post in retrospect is that there is no mention of the Go 1 guarantee. When Go 1.0 was released, the Go team promised not to break source compatibility. Of course, there have always been caveats, bugs, and exceptions, but by most reckonings, they have kept their word to a remarkable degree. To be fair to myself, at the time, Go 1.0.3 was the current version, so it was impossible for me to know that the Go 1 guarantee would last for a decade plus, but I think that it really was critical in the development of Go into the language it is today. Before Go 1, it was common for the Go team to issue changes to the language or the standard library that needed go fix to be run over a codebase to get it caught up to the new standard. Since then however, if you write a Go program, use the standard library, and don’t rely on unsafe features or security holes, your code should just keep on working indefinitely.

It’s a real breath of fresh air compared to other ecosystems, and it’s one of my favorite things as a developer. With other languages, upgrading to a new version (even a minor version) is fraught with worry that something, somewhere will break unexpectedly. Even when the breakage is declared in advance with deprecation warnings, it’s still churn that you have to make time to fix as part of the upgrade process. With Go, I just don’t worry about that. Yes, there can be bugs, but nothing is going to break on purpose just because it made life easier for someone else. It feels like the language team is working for me instead of against me.

What I got wrong

I would say that on the whole, the old post holds up pretty well and nothing in it was egregiously wrong. However, there are a few things where at the time I felt one way, but now I have a slightly more nuanced view without totally disagreeing with myself.

In the “good” section specifically, there was nothing that was actually bad exactly, but in hindsight, I do see more of the tradeoffs to concurrency.

I wrote at the time,

It works like you thought concurrency should work before you learned about concurrency. It’s nice. You don’t have to think about it too much. If you want to run a function concurrently, just do go function(arguments). If you want to communicate with the function, you can use channels, which are synchronous by default, meaning they pause execution until both sides are ready.

This is still true, and I still think working with concurrency in Go is very easy compared to something like Python. (Incidentally, I was writing before Promise and await were added to JavaScript to give context.) On the other hand, nothing in the type system prevents you from creating a data race, so you have to use the race detector religiously in testing, and using channels directly without following a well known pattern for creating structured concurrency is a surefire recipe for spaghetti code. It’s a tradeoff in that Go gives you just enough rope to hang yourself, but most of the time when you’re just writing a web server or something, you can get most of the benefits of concurrency without the drawbacks.

On the other hand, most of the things I listed in the “bad” section have mostly been non-problems for me in practice. I’m not sure why I was worried about the string type not having methods. The lack of a #! starting line for scripts turned out not to matter in practice. That Go isn’t DRY is more of a tradeoff than “bad” per se.

The lack of generics was an occasional problem, but there were usually pretty obvious workarounds for it, like falling back on dynamic typing, using reflection, or writing a code generator. I’m happy to have generics now, but it’s true that when we didn’t have it, it only occasionally presented a real problem. I am excited to watch how generics impact the future of Go, especially as the Go team work on iterators, but I do worry a bit about “useless uses of generics” where people try to shoehorn in generics where regular interfaces would work just fine. We’ll see how it goes.

The things I listed in the “meh” section also mostly continue to be tradeoffs, but in retrospect, I think Go took the better side of the tradeoff for the design space it occupies. Not having exceptions is a tradeoff, and while I might wish that Go had something like Zig’s try and errdefer, in practice, it’s fine. Using if err != nil is the worst system except all the other systems that have been tried from time to time, and it turns out to allow useful evolutions in user code.

Matklad put it well in a recent comment,

It seems we are roughly converging on the error handling. Midori, Go, Rust, Swift, Zig follow similarlish design, which isn’t quite checked exceptions, but is surprisingly close.

  • there’s a way to mark functions which can fail. Often, this is a property of the return type, rather than a property of the function (Result in Rust, error pair in Go, ! types in Zig, and bare throws decl in Midori and Swift)
  • we’ve cranked-up annotation burden and we mark not only throwing function declarations, but call-sites as well (try in Midori, Swift, Zig, ? in Rust, if err != nil in Go).
  • Default is existentially-typed AnyError (error in Go, Error in Swift, anyhow in Rust, anyerror in Zig). In general, the value is shifted to distinguishing between zero and one error, rather than exhaustively specifying the set of errors.

That seems right to me. Go’s error handling is more verbose than those other languages, but structurally, there’s a lot of commonality under the surface.

I listed that Go doesn’t have operator overloading, function/method overloading, or keyword arguments as being “meh” features, but I’m pretty happy about them now. The only case where I wish Go had operator overloading is for big.Int, and I’m hopeful that maybe someday those will be added to the language itself. Keyword arguments might be nice to have, but in practice, structs work just fine, and there’s always builders with method chaining for really hairy cases.

Takeaways

Before I wrote the post, I naively thought people would talk about how I had a section that dismissed object oriented programming out of hand. Instead people latched onto my misuse of the word “idiot” to describe people who debate the color of the bikeshed around public/private fields.

Go’s use of interfaces and lack of inheritance were probably the biggest questions to me when I wrote the article. I thought it was a good idea, but I was prepared to be surprised by experience and run into strong arguments against it. Hynek Schlawack wrote a great post in 2021 explaining why having interfaces and not having inheritance was a good design choice for Go. Basically, inheritance only makes sense when a subclass is a true specialization of the superclass, in which case Go’s type embedding works fine. So, I got the discussion I wanted, but it took about eight more years.

It would take until 2019 for Dan Abramov to write the definitive explanation of the experience of writing for Hacker News:

Congratulations!

Your project hit the front page of a popular news aggregator. Somebody visible in the community tweeted about it too. What are they saying?

Your heart sinks.

It’s not that people didn’t like the project. You know it has tradeoffs and expected people to talk about them. But that’s not what happened.

Instead, the comments are largely irrelevant to your idea.

The top comment thread picks on the coding style in a README example. It turns into an argument about indentation with over a hundred replies and a brief history of how different programming languages approached formatting. There are obligatory mentions of gofmt and Python. Have you tried Prettier?

Confused, you close the tab.

What happened?

It might be that your idea is simply not as interesting as you thought. That happens. It might also be that you poorly explained it for a casual visitor.

However, there might be another reason why you didn’t get relevant feedback.

We tend to discuss things that are easy to talk about.

I’ve come to accept that people on discussion boards (myself included) talk about whatever they were already thinking about when we see the title of a post, and not whatever the article itself proposes. It is what it is, and it’s not going to change any time soon.

I think Rachel’s Run XOR Use rule about running an IRC chat or message board also applies to writing XOR discussing a blog post. If you write the post, you have to be prepared for the discussion to go to something you yourself weren’t even thinking about.

In terms of Go, I’m about as happy with the language as I’ve ever been. It’s not as fast as Rust, but it already well outpaces my scaling needs, and without too much boilerplate or too much temptation to abstraction. I think the language team has good instincts, and they tend to move at a measured pace in the right direction. I’m excited to see what happens next.

It’s hard to make predictions, especially about the future. Will I still be using Go in a decade? I don’t know. A decade feels like a longer time in prospect than in retrospect. Maybe we will all just be spot checking the output of code generated by AI by then, as grim as that sounds. But for now at least, I’m happy to use it as my core language.

Here’s the original conclusion to the post with my 2023 updates:

Go is rad. It’s not [edit: It is] my everyday language (that’s still [edit: it used to be] Python), but [edit: and] it’s definitely a fun language to use, and one that would be great for doing big projects in. If you’re interested in learning about Go, I recommend doing the tour then reading the spec while you put test programs into the Playground. The spec is very short and quite readable, so it’s a great way to learn Go.

See you in 2033 for “Ten Years of ‘Ten Years of “Go” ’ ”.