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.
... 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@latestireturn ./...
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
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.
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.
> 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.
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
> 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"?
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.
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.
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.
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.
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.
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?
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 :)
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.
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.
41 comments
[ 3.5 ms ] story [ 74.1 ms ] threadThe 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`
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
go install github.com/butuzov/ireturn/cmd/ireturn@latest ireturn ./...
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. #£@&+!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
In Python you'll actually get a RuntimeError here, because Python detects that you're modifying the dictionary while iterating over it.
> 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.
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.
How does it cancel in-progress goroutines when the provided context is cancelled?
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() }.
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.
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).
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
> 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
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.
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
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 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.
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.
func Foo() { try { maybePanic() } catch (err any) { doSomething(err) }
}vs
func Foo() { defer func() { if err := recover(); err != nil { doSomething(err) } }()
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.
It's all just spelling. Your compiler just turns
into 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.
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.
One problem with using a channel as a semaphore is you need to track if you've closed the channel when "releasing".
https://pkg.go.dev/golang.org/x/sync/semaphore#Weighted.Acqu...
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?
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...
Try utf8.RuneCountInString().
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.