41 comments

[ 3.5 ms ] story [ 74.1 ms ] thread
Ah the old nil values boxed into non-nil interfaces. Even after 8 years writing go code almost every day this still bites me occasionally. I've never seen code that actually uses this. I understand why it is the way it is but I hate it.
Did not know about index-based string interpolation. Useful!

The part about changing a map while iterating is wrong though. The reason you may or may not get it is because go iterates in intentionally random order. It's nothing to do with speed. It's to prevent users from depending on the iteration order. It randomly chooses starting bucket and then goes in circular order, as well as randomly generates a perm of 0..7 inside each bucket. So if your edit goes into a bucket or a slot already visited then it won't be there.

Also, python is not an example to the contrary. Modifying python dicts while iterating is a `RuntimeError: dictionary changed size during iteration`

Great list of why one can love and hate Go. I really did enjoy writing it but you never get the sense that you can be truly certain your code is robust because of subtle behaviour around nil.
As a Go learner, the best explanation that has made sense to me is that interface types essentially just compose two pointers:

P1: The type and its method vtable

P2: The value

Once I understood that I could intuit how a nil Foo was not a nil Bar and not an untyped nil either

  The time.After function creates a channel that will be sent a message after x seconds.
Or, will it? https://github.com/golang/go/issues/24595

  ... even though the value is nil, the type of the variable is a non-nil interface... Go "boxes" that value in an interface, which is not nil. This can really bite you if you return interfaces from functions
Bit me when I was noob. These days, I fail build if ireturn fails.

go install github.com/butuzov/ireturn/cmd/ireturn@latest ireturn ./...

  Go 1.25 introduced a waitgroup.Go function that lets you add Go routines to a waitgroup more easily.
sync.WaitGroup(n) panics if Add(x) is called after a Done(-1) & n isn't now zero. Unsure if WaitGroups and easy belong in the same sentence. May be they do, but I'd rather reimplement Java's CountDownLatch & CyclicBarrier APIs in Go instead.

  When you embed structs, you also implicitly promote any methods they contain ... Say, for instance, you embed a time.Time struct onto a JSON response field and try to marshal that parent ... Since the time.Time method has a MarshalJSON() method, the compiler will run that over the regular marshalling behavior
#£@&+!
As somebody who only views Go from a distance, I see this list as a combination of „what‘s the big deal?“ and „please don‘t“.
If you don't write Go at all, this blog post isn't going to be useful to you, and you aren't its audience. It's fine not to have an apt take for a programming-language-specific article!
> This is helpful if you have to interpolate the same value multiple times and want to reduce repetition and make the interpolation easier to follow.

Is index-based string interpolation easier to follow? I would find it easier to understand a string interpolation when the variable name is right there, rather than having to count along the arguments to find the particular one it's referencing

> This is different than, for instance, python, which has a “stable insertion order” that guarantees that this won’t happen. The reason Go does this: speed!

In Python you'll actually get a RuntimeError here, because Python detects that you're modifying the dictionary while iterating over it.

(comment deleted)
Go has certainly come a long ways from its initial mission to be a simple language for Rob Pike's simple coworkers.

    type User struct {
        Name     string `json:"name"`
        Password string `json:"-"`
        Email    string `json:"email"`
    }
So you can specify how to serialize a struct in json using raw string literals containing arbitrary metadata. And json:"X" means to serialize it to X, except the special value "-" means "omit this one," except "-," means that its name is "-". Got it.
Annotations have been part of programming for ages. This is nothing new or out of the ordinary functionality.
> The wg.Go Function

> Go 1.25 introduced a waitgroup.Go function that lets you add Go routines to a waitgroup more easily. It takes the place of using the go keyword, [...]

99% of the time, you don't want to use sync.WaitGroup, but rather errgroup.Group. This is basically sync.WaitGroup with error handling. It also has optional context/cancellation support. See https://pkg.go.dev/golang.org/x/sync/errgroup

I know it's not part of the standard library, but it's part of the http://golang.org/x/ packages. TBH, golang.org/x/ is stuff that should be in the standard library but isn't, for some reason.

I never used errgroup but I realize that it's essentially the same what I end up implementing anyways.

With standard waitgroups I always move my states as a struct with something like a nested *data struct and an err property which is then pushed through the channel. But this way, my error handling is after the read instead of right at the Wait() call.

Wow how did I not know of this?!

How does it cancel in-progress goroutines when the provided context is cancelled?

Looks like this isn’t usable with functions that take parameters. For that, you need wg.Add.
Did anyone else read "Go subtitles" instead of the actual title?
Great list! Reminds me to check out more of the new stuff in 1.25.

The one thing I wish Go had more than anything is read-only slices (like C#).

The one thing I wish more other languages had that Go has is structural typing (anything with Foo() method can be used as an interface { Foo() }.

Yeah, having mutability optional would be great. It would also allow a lot of data to pass through the stack instead of heap due to pointers, which Go is riddled with for absolutely no reason(imo).

On the other hand, now that we have iterators in Go, you can create a wrapper for []byte that only allows reading, yet is iterable.

But then we're abstracting away, which is a no-go in Go and also creates problems later on when you get custom types with custom logic.

Go's subtle footguns are definitely its worst aspect. I say that as a "Go fanboy" (I confess). But I think its also worth asking WHY many of these footguns continue to exist from early Go versions - and the answer is that Go takes versioning very seriously and sticking to major version 1 very seriously.

The upshot of this dogmatism is that its comparatively easy to dev on long-lived Go projects. If I join a new team with an old Go project, there's a very good chance that I'll be able to load it up in my IDE and get all of Go's excellent LSP, debug, linting, testing, etc. tooling going immediately. And when I start reading the code, its likely not going to look very different from a new Go project I'd start up today.

(BTW Thanks OP for these subtleties, there were a few things I learned about).

>As an additional complexity, although string literals are UTF-8 encoded, they are just aribtrary collections of bytes, which means you can technically have strings that have invalid data in them. In this case, Go replaces invalid UTF-8 data with replacement characters.

No, it's just doing the usual "replace unprintable characters when printing" behavior. The data is unchanged, you have no guarantees of UTF-8 validity at all: https://go.dev/play/p/IpYjcMqtmP0

FTA:

> Runes correspond to code points in Go, which are between 1 and 4 bytes long.

That's the dumbest thing I've read in this month. Why did they use the wrong word, sowing confusion¹, when any other programming language and the Unicode standard uses the correct expression "code point"?

¹ https://codepoints.net/runic already exists

> uses the correct expression "code point"

Actually no, these are Unicode scalars, not code points; they exclude the surrogate category.

I agree that rune is a very poor name for it. It both mistakes what runes actually are and clashes with the runic block. But C# has adopted the Rune name for some reason.

Rust simply calls these char, and OCaml uchar (unicode char), which are much better choices.

Seeing as two of the authors designed utf8 (or at least concurrent to others), I think it’s safe to defer to their expertise and nomenclature here.
There is mention of how len() is bytes, not “characters”. A further subtlety: a rune (codepoint) is still not necessarily a “character” in terms of what is displayed for users — that would be a “grapheme”.

A grapheme can be multiple codepoints, with modifiers, joiners, etc.

This is true in all languages, it’s a Unicode thing, not a Go thing. Shameless plug, here is a grapheme tokenizer for Go: https://github.com/clipperhouse/uax29/tree/master/graphemes

len() is also returning int instead of uint/uint64 in Go.

I do not use Go but ran into this when I had to write a Go wrapper for some Rust stuff the other day. I was baffled.

I had a “wtf” moment when using Go around panic() and recover()

I was so surprised by the design choice to need to put recover in in deferred function calls. It’s crazy to smush together the error handling and normal execution code.

It's cause it's not normal error handling to use recover(). In smaller codebases, panic probably should not be present. For larger codebases, recover should be in place only in very very sparse locations (e.g. at the top level http handler middleware to catch panics caused by unreliable code). But in general, returning errors is supposed to be how all errors are signaled. I've always loved the semantic distinction between panics vs errors in go, they feel sooo much clearer than "normal" exception handling (try ... catch) in other languages which syntactically equivocate such common cases as "this file doesn't exist" with "the program is misbehaving due to physical RAM corruption". I think it's great that panic vs errors makes that a brighter line.

Assuming recover has to exist, I think forcing it to be in a deferred function is genius because it composes so well with how defers work in go. It's guaranteed to run "when the function returns" which is exactly the time to catch such truly catastrophic behaviors.

Semantics. Go at least does not restrict you to wrap every single panicky call.

func Foo() { try { maybePanic() } catch (err any) { doSomething(err) }

  .. more code
}

vs

func Foo() { defer func() { if err := recover(); err != nil { doSomething(err) } }()

  maybePanic()

  .. more code
}
I balked a little when the article refers to format strings as "string interpolation" but there's multiple comments here running with it. Am I out of date and we just call that string interpolation these days?

I also found this very confusing:

> When updating a map inside of a loop there’s no guarantee that the update will be made during that iteration. The only guarantee is that by the time the loop finishes, the map contains your updates.

That's totally wrong, right? It makes it sound magical. There's a light explainer but I think it would be a lot more clear to say that of course the update is made immediately, but the "range" iterator may not see it.

"Am I out of date and we just call that string interpolation these days?"

It's all just spelling. Your compiler just turns

    x = "I want ${number/12|round} dozen eggs, ${name|namecase}"
into

    x = StrCon("I want ", round(number/12), " dozen eggs, ", namecase(name))
anyhow. It's not a huge transform.

I think people get bizarrely hung up on the tiny details of this between languages... but then, I think that extensive use of string interpolation is generally a code smell at best anyhow, so I'm probably off the beaten path in more than one way here.

Mutating maps during iteration is a big red flag.
my favorite go trick is a simple semaphore using make(chan struct{}, CONCURRENCY) to throttle REST api calls and other concurrent goroutines.

It’s really elegant acquisition by reading, and releasing the semaphore by writing.

Great to limit your rest / http crawlers to 8 concurrent calls like a web browser.

FTA: “In Go, empty structs occupy zero bytes. The Go runtime handles all zero-sized allocations, including empty structs, by returning a single, special memory address that takes up no space.

This is why they’re commonly used to signal on channels when you don’t actually have to send any data. Compare this to booleans, which still must occupy some space.”

I would expect the compiler to ensure that all references to true and false reference single addresses, too. So, at best, the difference of the more obscure code is to, maybe, gain 8 bytes. What do I overlook?

That wouldn’t work because boolean variables can be mutated (whereas you can’t mutate a zero-sized value).
One of the cooler things in Go these days is that the new function based iterators are based on coroutines, and you can use the iter.Pull function to abuse that :)
Isn't using time.After for timeouts a bit of an anti-pattern? There is no way to cancel the pending computation.
The wording "Subtleties" used here is some weird/improper. I see nothing subtle here. They are all basic knowledge a qualified Go programmer should know about.

They are many real subtleties in Go, which even many professional Go programmers are not aware of. Here are some of them: https://go101.org/blog/2025-10-22-some-real-go-subtleties.ht...

> Using len() with Strings, and UTF-8 Gotchas

Try utf8.RuneCountInString().

My opinion after using go professionally for ~2 years and repeatedly running into gotchas such as https://go.dev/blog/loopvar-preview is that it's just not a good language.

A lot of people praise it for it's "simplicity" and "explicitness" but frankly, even just figuring out whether something is being passed by reference or value is often complicated. If you're writing code where you never care about that, sure. But for any real project it's not actually better or simpler than C++ or Python.